diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f5a69f4f..5233ef190 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -lightningkmp = "1.11.0" +lightningkmp = "1.11.1-CARDPAYMENT" secp256k1 = "0.21.0" # keep in check with lightning-kmp secp version kotlin = "2.2.10" diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index 3f26af807..3544b7c46 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -30,12 +30,8 @@ DC0732EC263CA6C3004CB88D /* PaymentOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0732EB263CA6C3004CB88D /* PaymentOptionsView.swift */; }; DC08A51827FB39530041603B /* AnimatedChevron.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC08A51727FB39530041603B /* AnimatedChevron.swift */; }; DC08A51A27FB6C5F0041603B /* TopTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC08A51927FB6C5F0041603B /* TopTab.swift */; }; - DC08FC232DA978050003E88C /* CapabilitiesContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC08FC222DA978050003E88C /* CapabilitiesContainer.swift */; }; - DC08FC292DA978810003E88C /* Array+Read.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC08FC272DA978810003E88C /* Array+Read.swift */; }; DC08FC2B2DA978940003E88C /* CommandAPDU.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC08FC2A2DA978940003E88C /* CommandAPDU.swift */; }; DC08FC2F2DA9796A0003E88C /* HceWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC08FC2E2DA9796A0003E88C /* HceWriter.swift */; }; - DC08FC6E2DA9ABE80003E88C /* Ndef.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC08FC6D2DA9ABE80003E88C /* Ndef.swift */; }; - DC08FC732DAEABC20003E88C /* ByteArrayConversions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC08FC722DAEABB00003E88C /* ByteArrayConversions.swift */; }; DC09085825B5E43900A46136 /* String+VersionComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC09085725B5E43900A46136 /* String+VersionComparison.swift */; }; DC09086325B626B300A46136 /* AppStatusPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC09086225B626B300A46136 /* AppStatusPopover.swift */; }; DC0994B0263A074C003031CA /* InfoGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0994AF263A074C003031CA /* InfoGrid.swift */; }; @@ -53,10 +49,6 @@ DC118C0027B4523B0080BBAC /* CommentSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC118BFF27B4523B0080BBAC /* CommentSheet.swift */; }; DC118C0827B457520080BBAC /* FetchActivityNotice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC118C0727B457520080BBAC /* FetchActivityNotice.swift */; }; DC118C0C27B561210080BBAC /* CurrencyAmount.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC118C0B27B561210080BBAC /* CurrencyAmount.swift */; }; - DC13A44E2E902F9200E8523F /* AppIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC13A44D2E902F8E00E8523F /* AppIdentifier.swift */; }; - DC13A4502E902FBC00E8523F /* AppIdentifier+Foreground.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC13A44F2E902FB600E8523F /* AppIdentifier+Foreground.swift */; }; - DC13A4512E902FD300E8523F /* AppIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC13A44D2E902F8E00E8523F /* AppIdentifier.swift */; }; - DC13A4542E902FF300E8523F /* AppIdentifier+Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC13A4522E902FD900E8523F /* AppIdentifier+Background.swift */; }; DC142135261E72320075857A /* AboutHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC142134261E72320075857A /* AboutHTML.swift */; }; DC142140261E72E40075857A /* AnyHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC14213F261E72E40075857A /* AnyHTML.swift */; }; DC16965F27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC16965E27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift */; }; @@ -168,7 +160,6 @@ DC4CF3CE2BE96C36003A957F /* DisablePinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4CF3CD2BE96C36003A957F /* DisablePinView.swift */; }; DC4CF3D02BEA8C13003A957F /* EditPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4CF3CF2BEA8C13003A957F /* EditPinView.swift */; }; DC5567452C2F1A6900008E11 /* ContactsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5567442C2F1A6900008E11 /* ContactsList.swift */; }; - DC5631C52C541E5C00DCB5BF /* Experimental.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C42C541E5C00DCB5BF /* Experimental.swift */; }; DC5631C72C5944CF00DCB5BF /* KotlinExtensions+Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */; }; DC5631C82C59466000DCB5BF /* KotlinExtensions+Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */; }; DC5631CA2C597B8600DCB5BF /* SourceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C92C597B8600DCB5BF /* SourceInfo.swift */; }; @@ -237,7 +228,7 @@ DC5F97142E2FDBDA0052CDE6 /* WalletInfoReceive.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5F97132E2FDBD20052CDE6 /* WalletInfoReceive.swift */; }; DC5F97162E2FEDA30052CDE6 /* WalletInfoSend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5F97152E2FED9F0052CDE6 /* WalletInfoSend.swift */; }; DC5F971A2E32E4930052CDE6 /* GlobalEnvironmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5F97192E32E48B0052CDE6 /* GlobalEnvironmentView.swift */; }; - DC635AA42E4264BF0038ED92 /* Data+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6EF2566D0A60009B01E /* Data+Hexadecimal.swift */; }; + DC635AA42E4264BF0038ED92 /* Digest+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6EF2566D0A60009B01E /* Digest+Hexadecimal.swift */; }; DC635AA62E429D8B0038ED92 /* LockPinRequired.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC635AA52E429D860038ED92 /* LockPinRequired.swift */; }; DC63A3762B965D54008E040E /* InboundFeeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC63A3752B965D54008E040E /* InboundFeeSheet.swift */; }; DC63BDF429AE44380067A361 /* NotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC63BDF329AE44380067A361 /* NotificationsManager.swift */; }; @@ -302,10 +293,13 @@ DC808E9D2D554A430019AE30 /* DisablingTorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808E9C2D554A3E0019AE30 /* DisablingTorSheet.swift */; }; DC81B79F25BF2AA200F5A52C /* MVI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC81B79E25BF2AA200F5A52C /* MVI.swift */; }; DC82EED629789853007A5853 /* TxHistoryExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC82EED529789853007A5853 /* TxHistoryExporter.swift */; }; + DC87582A2E81B01000A166EB /* Experimental.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8758292E81B00900A166EB /* Experimental.swift */; }; DC89857F25914747007B253F /* UIApplicationState+Phoenix.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89857E25914747007B253F /* UIApplicationState+Phoenix.swift */; }; DC8D94142CA7015F00EE844E /* ChannelFundingProblem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8D94132CA7015F00EE844E /* ChannelFundingProblem.swift */; }; DC9130A02AE045FA00F9B8C6 /* Sequence+Sum.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC59377027516296003B4B53 /* Sequence+Sum.swift */; }; DC949E6A2B45B1EC00E80BB5 /* LiquidityAdsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC949E692B45B1EC00E80BB5 /* LiquidityAdsHelp.swift */; }; + DC9701302E74C29D000B1E59 /* DnaCommunicator in Frameworks */ = {isa = PBXBuildFile; productRef = DC97012F2E74C29D000B1E59 /* DnaCommunicator */; }; + DC9701322E74C2F6000B1E59 /* DnaCommunicator in Frameworks */ = {isa = PBXBuildFile; productRef = DC9701312E74C2F6000B1E59 /* DnaCommunicator */; }; DC98D3962AF170AC005BD177 /* PaymentWarningPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC98D3952AF170AC005BD177 /* PaymentWarningPopover.swift */; }; DC98D3982AF2AE41005BD177 /* ReceiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC98D3972AF2AE41005BD177 /* ReceiveView.swift */; }; DC9933322CC03D7500EB3100 /* ContactsListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9933312CC03D7500EB3100 /* ContactsListSheet.swift */; }; @@ -347,7 +341,7 @@ DCA849E2281333EB000FADE1 /* currencyFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA849E1281333EB000FADE1 /* currencyFormattingTests.swift */; }; DCAB8C332E9EA89A0075462A /* Array+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAB8C322E9EA8950075462A /* Array+IsNotEmpty.swift */; }; DCAC5B7027726FC80077BB98 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAC5B6F27726FC80077BB98 /* DeepLink.swift */; }; - DCACF6F02566D0A60009B01E /* Data+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6EF2566D0A60009B01E /* Data+Hexadecimal.swift */; }; + DCACF6F02566D0A60009B01E /* Digest+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6EF2566D0A60009B01E /* Digest+Hexadecimal.swift */; }; DCACF6FA2566D0BA0009B01E /* KeyStoreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6F52566D0BA0009B01E /* KeyStoreError.swift */; }; DCACF6FB2566D0BA0009B01E /* SystemKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6F62566D0BA0009B01E /* SystemKeychain.swift */; }; DCACF6FC2566D0BA0009B01E /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCACF6F72566D0BA0009B01E /* Keychain.swift */; }; @@ -374,6 +368,39 @@ DCB876302735AA7300657570 /* UserDefaults+Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB8762F2735AA7300657570 /* UserDefaults+Serialization.swift */; }; DCB876322735AAB500657570 /* UserDefaults+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB876312735AAB500657570 /* UserDefaults+Codable.swift */; }; DCBA371B2758076F00610EC8 /* SyncSeedManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA371A2758076F00610EC8 /* SyncSeedManager.swift */; }; + DCBA3A5A2E60F0EC000BC8D3 /* LnurlWithdrawNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A592E60F0EC000BC8D3 /* LnurlWithdrawNotification.swift */; }; + DCBA3A5B2E60F0EC000BC8D3 /* LnurlWithdrawNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A592E60F0EC000BC8D3 /* LnurlWithdrawNotification.swift */; }; + DCBA3A602E60F191000BC8D3 /* CardRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A5D2E60F191000BC8D3 /* CardRequest.swift */; }; + DCBA3A612E60F191000BC8D3 /* WithdrawRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A5E2E60F191000BC8D3 /* WithdrawRequest.swift */; }; + DCBA3A622E60F191000BC8D3 /* CardRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A5D2E60F191000BC8D3 /* CardRequest.swift */; }; + DCBA3A632E60F191000BC8D3 /* WithdrawRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A5E2E60F191000BC8D3 /* WithdrawRequest.swift */; }; + DCBA3A672E60FFC5000BC8D3 /* AppIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A642E60FFC5000BC8D3 /* AppIdentifier.swift */; }; + DCBA3A682E60FFC5000BC8D3 /* AppIdentifier+Foreground.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A662E60FFC5000BC8D3 /* AppIdentifier+Foreground.swift */; }; + DCBA3A6A2E60FFC5000BC8D3 /* AppIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A642E60FFC5000BC8D3 /* AppIdentifier.swift */; }; + DCBA3A6C2E60FFC5000BC8D3 /* AppIdentifier+Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A652E60FFC5000BC8D3 /* AppIdentifier+Background.swift */; }; + DCBA3A712E6100A9000BC8D3 /* NFCReaderError+Ignore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A6E2E6100A9000BC8D3 /* NFCReaderError+Ignore.swift */; }; + DCBA3A722E6100A9000BC8D3 /* NfcWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A6F2E6100A9000BC8D3 /* NfcWriter.swift */; }; + DCBA3A732E6100A9000BC8D3 /* BoltCardScan.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3A6D2E6100A9000BC8D3 /* BoltCardScan.swift */; }; + DCBA3AA32E61F1ED000BC8D3 /* SyncBackupManager+Cards.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AA22E61F1ED000BC8D3 /* SyncBackupManager+Cards.swift */; }; + DCBA3AA52E61F3BB000BC8D3 /* KotlinExtensions+Cards.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AA42E61F3BB000BC8D3 /* KotlinExtensions+Cards.swift */; }; + DCBA3AB72E61FE26000BC8D3 /* ResetSuccessSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AB22E61FE26000BC8D3 /* ResetSuccessSheet.swift */; }; + DCBA3AB82E61FE26000BC8D3 /* WriteErrorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AB52E61FE26000BC8D3 /* WriteErrorSheet.swift */; }; + DCBA3AB92E61FE26000BC8D3 /* PrerequisitesSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AAF2E61FE26000BC8D3 /* PrerequisitesSheet.swift */; }; + DCBA3ABA2E61FE26000BC8D3 /* ManageBoltCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AAD2E61FE26000BC8D3 /* ManageBoltCard.swift */; }; + DCBA3ABB2E61FE26000BC8D3 /* ReadCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AB02E61FE26000BC8D3 /* ReadCardSheet.swift */; }; + DCBA3ABC2E61FE26000BC8D3 /* NewCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AAE2E61FE26000BC8D3 /* NewCardSheet.swift */; }; + DCBA3ABD2E61FE26000BC8D3 /* BoltCardsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AA82E61FE26000BC8D3 /* BoltCardsHelp.swift */; }; + DCBA3ABE2E61FE26000BC8D3 /* DeleteCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AAB2E61FE26000BC8D3 /* DeleteCardSheet.swift */; }; + DCBA3ABF2E61FE26000BC8D3 /* SimulatorWriteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AB42E61FE26000BC8D3 /* SimulatorWriteSheet.swift */; }; + DCBA3AC12E61FE26000BC8D3 /* SimulatorPasteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AB32E61FE26000BC8D3 /* SimulatorPasteSheet.swift */; }; + DCBA3AC22E61FE26000BC8D3 /* CardOptionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AAA2E61FE26000BC8D3 /* CardOptionsSheet.swift */; }; + DCBA3AC32E61FE26000BC8D3 /* BoltCardInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AA72E61FE26000BC8D3 /* BoltCardInput.swift */; }; + DCBA3AC42E61FE26000BC8D3 /* ResetCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AB12E61FE26000BC8D3 /* ResetCardSheet.swift */; }; + DCBA3AC52E61FE26000BC8D3 /* LnurlwRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AAC2E61FE26000BC8D3 /* LnurlwRegistration.swift */; }; + DCBA3AC62E61FE26000BC8D3 /* ArchiveCardSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AA62E61FE26000BC8D3 /* ArchiveCardSheet.swift */; }; + DCBA3ACA2E6606DC000BC8D3 /* KotlinExtensions+Cards.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA3AA42E61F3BB000BC8D3 /* KotlinExtensions+Cards.swift */; }; + DCBA3ACB2E6609BC000BC8D3 /* KotlinEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3780382C04D60400937C8E /* KotlinEnums.swift */; }; + DCBA3CCE2E6F6427000BC8D3 /* DnaCommunicator in Frameworks */ = {isa = PBXBuildFile; productRef = DCBA3CCD2E6F6427000BC8D3 /* DnaCommunicator */; }; DCBA60CD2C909C7600878895 /* SendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA60CC2C909C7600878895 /* SendView.swift */; }; DCBA60CF2C90E41000878895 /* ScanQrCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA60CE2C90E41000878895 /* ScanQrCodeView.swift */; }; DCBA60D42C93544C00878895 /* InsetGroupBoxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCBA60D32C93544C00878895 /* InsetGroupBoxStyle.swift */; }; @@ -430,6 +457,8 @@ DCE7233027B167240017CF56 /* SyncSeedManager_Actor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE7232F27B167240017CF56 /* SyncSeedManager_Actor.swift */; }; DCE77A5627C5240500F0FA24 /* TLSConnectionCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE77A5527C5240500F0FA24 /* TLSConnectionCheck.swift */; }; DCE77A5827C671D600F0FA24 /* ElectrumAddressSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE77A5727C671D600F0FA24 /* ElectrumAddressSheet.swift */; }; + DCE8EC5B2E79E58A0091B6F0 /* CardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE8EC5A2E79E5860091B6F0 /* CardResponse.swift */; }; + DCE8EC5C2E79E58A0091B6F0 /* CardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE8EC5A2E79E5860091B6F0 /* CardResponse.swift */; }; DCEAE5B92943D64B00320C46 /* MsatRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCEAE5B82943D64B00320C46 /* MsatRange.swift */; }; DCEB2795282D7A9F0096B87E /* KotlinPublishers+Phoenix.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46BAEF26CACCF700E760A6 /* KotlinPublishers+Phoenix.swift */; }; DCEB2796282D7AAB0096B87E /* KotlinTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC74174A270F332700F7E3E3 /* KotlinTypes.swift */; }; @@ -447,6 +476,7 @@ DCFA8759260E6F2E00AE8953 /* IntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFA8758260E6F2E00AE8953 /* IntroView.swift */; }; DCFA876D260E91E600AE8953 /* IntroContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFA876C260E91E600AE8953 /* IntroContainer.swift */; }; DCFAEFC92A72F48700330088 /* SwapInWalletDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFAEFC82A72F48700330088 /* SwapInWalletDetails.swift */; }; + DCFB00E82E6F6488002F5282 /* DnaCommunicator in Frameworks */ = {isa = PBXBuildFile; productRef = DCFB00E72E6F6488002F5282 /* DnaCommunicator */; }; DCFB8DF72A94066100947698 /* Task+Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFB8DF62A94066100947698 /* Task+Sleep.swift */; }; DCFB8DF92A94112A00947698 /* Dictionary+MapKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFB8DF82A94112A00947698 /* Dictionary+MapKeys.swift */; }; DCFBC5592AE2CFEF00E3A418 /* BizNotificationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFBC5582AE2CFEF00E3A418 /* BizNotificationCell.swift */; }; @@ -536,12 +566,8 @@ DC0732EB263CA6C3004CB88D /* PaymentOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentOptionsView.swift; sourceTree = ""; }; DC08A51727FB39530041603B /* AnimatedChevron.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedChevron.swift; sourceTree = ""; }; DC08A51927FB6C5F0041603B /* TopTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopTab.swift; sourceTree = ""; }; - DC08FC222DA978050003E88C /* CapabilitiesContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilitiesContainer.swift; sourceTree = ""; }; - DC08FC272DA978810003E88C /* Array+Read.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Read.swift"; sourceTree = ""; }; DC08FC2A2DA978940003E88C /* CommandAPDU.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandAPDU.swift; sourceTree = ""; }; DC08FC2E2DA9796A0003E88C /* HceWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HceWriter.swift; sourceTree = ""; }; - DC08FC6D2DA9ABE80003E88C /* Ndef.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ndef.swift; sourceTree = ""; }; - DC08FC722DAEABB00003E88C /* ByteArrayConversions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteArrayConversions.swift; sourceTree = ""; }; DC09085725B5E43900A46136 /* String+VersionComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+VersionComparison.swift"; sourceTree = ""; }; DC09086225B626B300A46136 /* AppStatusPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatusPopover.swift; sourceTree = ""; }; DC0994AF263A074C003031CA /* InfoGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoGrid.swift; sourceTree = ""; }; @@ -559,9 +585,6 @@ DC118BFF27B4523B0080BBAC /* CommentSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentSheet.swift; sourceTree = ""; }; DC118C0727B457520080BBAC /* FetchActivityNotice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchActivityNotice.swift; sourceTree = ""; }; DC118C0B27B561210080BBAC /* CurrencyAmount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyAmount.swift; sourceTree = ""; }; - DC13A44D2E902F8E00E8523F /* AppIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIdentifier.swift; sourceTree = ""; }; - DC13A44F2E902FB600E8523F /* AppIdentifier+Foreground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppIdentifier+Foreground.swift"; sourceTree = ""; }; - DC13A4522E902FD900E8523F /* AppIdentifier+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppIdentifier+Background.swift"; sourceTree = ""; }; DC142134261E72320075857A /* AboutHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutHTML.swift; sourceTree = ""; }; DC14213F261E72E40075857A /* AnyHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyHTML.swift; sourceTree = ""; }; DC16965E27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinExtensions+Currency.swift"; sourceTree = ""; }; @@ -665,7 +688,6 @@ DC4CF3CD2BE96C36003A957F /* DisablePinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisablePinView.swift; sourceTree = ""; }; DC4CF3CF2BEA8C13003A957F /* EditPinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPinView.swift; sourceTree = ""; }; DC5567442C2F1A6900008E11 /* ContactsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsList.swift; sourceTree = ""; }; - DC5631C42C541E5C00DCB5BF /* Experimental.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Experimental.swift; sourceTree = ""; }; DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinExtensions+Manager.swift"; sourceTree = ""; }; DC5631C92C597B8600DCB5BF /* SourceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceInfo.swift; sourceTree = ""; }; DC5895C92E551A4E00CBCCF9 /* AnyAsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyAsyncSequence.swift; sourceTree = ""; }; @@ -773,6 +795,7 @@ DC808E9C2D554A3E0019AE30 /* DisablingTorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisablingTorSheet.swift; sourceTree = ""; }; DC81B79E25BF2AA200F5A52C /* MVI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MVI.swift; sourceTree = ""; }; DC82EED529789853007A5853 /* TxHistoryExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxHistoryExporter.swift; sourceTree = ""; }; + DC8758292E81B00900A166EB /* Experimental.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Experimental.swift; sourceTree = ""; }; DC89857E25914747007B253F /* UIApplicationState+Phoenix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplicationState+Phoenix.swift"; sourceTree = ""; }; DC8D94132CA7015F00EE844E /* ChannelFundingProblem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelFundingProblem.swift; sourceTree = ""; }; DC949E692B45B1EC00E80BB5 /* LiquidityAdsHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidityAdsHelp.swift; sourceTree = ""; }; @@ -808,7 +831,7 @@ DCA849E1281333EB000FADE1 /* currencyFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = currencyFormattingTests.swift; sourceTree = ""; }; DCAB8C322E9EA8950075462A /* Array+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+IsNotEmpty.swift"; sourceTree = ""; }; DCAC5B6F27726FC80077BB98 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; - DCACF6EF2566D0A60009B01E /* Data+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Hexadecimal.swift"; sourceTree = ""; }; + DCACF6EF2566D0A60009B01E /* Digest+Hexadecimal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Digest+Hexadecimal.swift"; sourceTree = ""; }; DCACF6F52566D0BA0009B01E /* KeyStoreError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyStoreError.swift; sourceTree = ""; }; DCACF6F62566D0BA0009B01E /* SystemKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemKeychain.swift; sourceTree = ""; }; DCACF6F72566D0BA0009B01E /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; @@ -837,6 +860,32 @@ DCB8762F2735AA7300657570 /* UserDefaults+Serialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Serialization.swift"; sourceTree = ""; }; DCB876312735AAB500657570 /* UserDefaults+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Codable.swift"; sourceTree = ""; }; DCBA371A2758076F00610EC8 /* SyncSeedManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSeedManager.swift; sourceTree = ""; }; + DCBA3A592E60F0EC000BC8D3 /* LnurlWithdrawNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LnurlWithdrawNotification.swift; sourceTree = ""; }; + DCBA3A5D2E60F191000BC8D3 /* CardRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardRequest.swift; sourceTree = ""; }; + DCBA3A5E2E60F191000BC8D3 /* WithdrawRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithdrawRequest.swift; sourceTree = ""; }; + DCBA3A642E60FFC5000BC8D3 /* AppIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIdentifier.swift; sourceTree = ""; }; + DCBA3A652E60FFC5000BC8D3 /* AppIdentifier+Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppIdentifier+Background.swift"; sourceTree = ""; }; + DCBA3A662E60FFC5000BC8D3 /* AppIdentifier+Foreground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppIdentifier+Foreground.swift"; sourceTree = ""; }; + DCBA3A6D2E6100A9000BC8D3 /* BoltCardScan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoltCardScan.swift; sourceTree = ""; }; + DCBA3A6E2E6100A9000BC8D3 /* NFCReaderError+Ignore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NFCReaderError+Ignore.swift"; sourceTree = ""; }; + DCBA3A6F2E6100A9000BC8D3 /* NfcWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NfcWriter.swift; sourceTree = ""; }; + DCBA3AA22E61F1ED000BC8D3 /* SyncBackupManager+Cards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncBackupManager+Cards.swift"; sourceTree = ""; }; + DCBA3AA42E61F3BB000BC8D3 /* KotlinExtensions+Cards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinExtensions+Cards.swift"; sourceTree = ""; }; + DCBA3AA62E61FE26000BC8D3 /* ArchiveCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveCardSheet.swift; sourceTree = ""; }; + DCBA3AA72E61FE26000BC8D3 /* BoltCardInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoltCardInput.swift; sourceTree = ""; }; + DCBA3AA82E61FE26000BC8D3 /* BoltCardsHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoltCardsHelp.swift; sourceTree = ""; }; + DCBA3AAA2E61FE26000BC8D3 /* CardOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardOptionsSheet.swift; sourceTree = ""; }; + DCBA3AAB2E61FE26000BC8D3 /* DeleteCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteCardSheet.swift; sourceTree = ""; }; + DCBA3AAC2E61FE26000BC8D3 /* LnurlwRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LnurlwRegistration.swift; sourceTree = ""; }; + DCBA3AAD2E61FE26000BC8D3 /* ManageBoltCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageBoltCard.swift; sourceTree = ""; }; + DCBA3AAE2E61FE26000BC8D3 /* NewCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCardSheet.swift; sourceTree = ""; }; + DCBA3AAF2E61FE26000BC8D3 /* PrerequisitesSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrerequisitesSheet.swift; sourceTree = ""; }; + DCBA3AB02E61FE26000BC8D3 /* ReadCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadCardSheet.swift; sourceTree = ""; }; + DCBA3AB12E61FE26000BC8D3 /* ResetCardSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetCardSheet.swift; sourceTree = ""; }; + DCBA3AB22E61FE26000BC8D3 /* ResetSuccessSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetSuccessSheet.swift; sourceTree = ""; }; + DCBA3AB32E61FE26000BC8D3 /* SimulatorPasteSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorPasteSheet.swift; sourceTree = ""; }; + DCBA3AB42E61FE26000BC8D3 /* SimulatorWriteSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorWriteSheet.swift; sourceTree = ""; }; + DCBA3AB52E61FE26000BC8D3 /* WriteErrorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteErrorSheet.swift; sourceTree = ""; }; DCBA60CC2C909C7600878895 /* SendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendView.swift; sourceTree = ""; }; DCBA60CE2C90E41000878895 /* ScanQrCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQrCodeView.swift; sourceTree = ""; }; DCBA60D32C93544C00878895 /* InsetGroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetGroupBoxStyle.swift; sourceTree = ""; }; @@ -882,6 +931,7 @@ DCE7232F27B167240017CF56 /* SyncSeedManager_Actor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSeedManager_Actor.swift; sourceTree = ""; }; DCE77A5527C5240500F0FA24 /* TLSConnectionCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSConnectionCheck.swift; sourceTree = ""; }; DCE77A5727C671D600F0FA24 /* ElectrumAddressSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElectrumAddressSheet.swift; sourceTree = ""; }; + DCE8EC5A2E79E5860091B6F0 /* CardResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardResponse.swift; sourceTree = ""; }; DCEAE5B82943D64B00320C46 /* MsatRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsatRange.swift; sourceTree = ""; }; DCEC6A1727A82A98002C20BA /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; DCED09D32625DBC4005D5EE2 /* AnimationCompletion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationCompletion.swift; sourceTree = ""; }; @@ -916,6 +966,9 @@ DC39D4E5286B4A7E0030F18D /* Popovers in Frameworks */, DCC46F1625C3521C005D32D9 /* FirebaseMessaging in Frameworks */, DCCFE6AB2B6430AB002FFF11 /* Logging in Frameworks */, + DCBA3CCE2E6F6427000BC8D3 /* DnaCommunicator in Frameworks */, + DCFB00E82E6F6488002F5282 /* DnaCommunicator in Frameworks */, + DC9701302E74C29D000B1E59 /* DnaCommunicator in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -938,6 +991,7 @@ buildActionMask = 2147483647; files = ( DCCFE6B72B682E22002FFF11 /* Logging in Frameworks */, + DC9701322E74C2F6000B1E59 /* DnaCommunicator in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -980,13 +1034,13 @@ 53BEF1337AFCFF0AE82A46BD /* utils */ = { isa = PBXGroup; children = ( + DCBA3A642E60FFC5000BC8D3 /* AppIdentifier.swift */, + DCBA3A652E60FFC5000BC8D3 /* AppIdentifier+Background.swift */, + DCBA3A662E60FFC5000BC8D3 /* AppIdentifier+Foreground.swift */, DC5F96D62E0B0C0C0052CDE6 /* ActivityView.swift */, DC74174C270F455D00F7E3E3 /* AES256.swift */, DCED09D32625DBC4005D5EE2 /* AnimationCompletion.swift */, 53BEFA112E0701FFD6B84217 /* AppColors.swift */, - DC13A44D2E902F8E00E8523F /* AppIdentifier.swift */, - DC13A4522E902FD900E8523F /* AppIdentifier+Background.swift */, - DC13A44F2E902FB600E8523F /* AppIdentifier+Foreground.swift */, DCFC72032862237400D6B293 /* Asserts.swift */, DC384D80265C12B700131772 /* Cache.swift */, DC5F96D42E0B0A8F0052CDE6 /* ColorScheme+Opposite.swift */, @@ -1100,6 +1154,7 @@ DC641C6F2820882E00862DCD /* prefs */, 53BEF1337AFCFF0AE82A46BD /* utils */, DCC4F30C2E4B9D9A00060704 /* notifications */, + DCBA3A5F2E60F191000BC8D3 /* withdraw */, DCACF6EE2566D0A60009B01E /* extensions */, DCA6DEC42829BD060073C658 /* xpc */, DCD1BFD42CE775CE00C2B811 /* nfc */, @@ -1150,25 +1205,6 @@ path = configuration; sourceTree = ""; }; - DC08FC6F2DAEAA5E0003E88C /* tools */ = { - isa = PBXGroup; - children = ( - DC08FC222DA978050003E88C /* CapabilitiesContainer.swift */, - DC08FC2A2DA978940003E88C /* CommandAPDU.swift */, - DC08FC6D2DA9ABE80003E88C /* Ndef.swift */, - ); - path = tools; - sourceTree = ""; - }; - DC08FC712DAEAA790003E88C /* extensions */ = { - isa = PBXGroup; - children = ( - DC08FC272DA978810003E88C /* Array+Read.swift */, - DC08FC722DAEABB00003E88C /* ByteArrayConversions.swift */, - ); - path = extensions; - sourceTree = ""; - }; DC09FC3A2D5AAA9000E6579A /* updates */ = { isa = PBXGroup; children = ( @@ -1269,6 +1305,7 @@ DC46BAF226CACCF700E760A6 /* KotlinAssociatedObject.swift */, DC3780382C04D60400937C8E /* KotlinEnums.swift */, DC49FE982AC49C6300D8D2E2 /* KotlinExtensions+Bitcoin.swift */, + DCBA3AA42E61F3BB000BC8D3 /* KotlinExtensions+Cards.swift */, DC0D2EA42939269800284608 /* KotlinExtensions+CloudKit.swift */, DC46BAF126CACCF700E760A6 /* KotlinExtensions+Conversion.swift */, DC16965E27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift */, @@ -1480,6 +1517,13 @@ path = tor; sourceTree = ""; }; + DC8758262E81AFDB00A166EB /* experimental */ = { + isa = PBXGroup; + children = ( + ); + path = experimental; + sourceTree = ""; + }; DC949E6B2B45FC5E00E80BB5 /* fees */ = { isa = PBXGroup; children = ( @@ -1577,10 +1621,12 @@ DCACF6DF2566CEC40009B01E /* advanced */ = { isa = PBXGroup; children = ( - DCFAEFC72A72F46D00330088 /* wallet */, + DCBA3AB62E61FE26000BC8D3 /* bolt card */, DCFFAADC2900218B004E3C11 /* channels */, + DC8758262E81AFDB00A166EB /* experimental */, 53BEF0A8669F9379E4E4596F /* logs */, - DC5631C42C541E5C00DCB5BF /* Experimental.swift */, + DCFAEFC72A72F46D00330088 /* wallet */, + DC8758292E81B00900A166EB /* Experimental.swift */, ); path = advanced; sourceTree = ""; @@ -1615,7 +1661,7 @@ DC5895C92E551A4E00CBCCF9 /* AnyAsyncSequence.swift */, DC2CE3AE29AFEB0500BA0B00 /* Bundle+Icon.swift */, DCEE3E532931446A00EB4DFF /* Collections+AsInt.swift */, - DCACF6EF2566D0A60009B01E /* Data+Hexadecimal.swift */, + DCACF6EF2566D0A60009B01E /* Digest+Hexadecimal.swift */, DC422F3229392ABD00E72253 /* Date+Format.swift */, DCFB8DF82A94112A00947698 /* Dictionary+MapKeys.swift */, DC5A935229846043004F19FD /* FileHandle+Async.swift */, @@ -1703,10 +1749,43 @@ path = environment; sourceTree = ""; }; + DCBA3A5F2E60F191000BC8D3 /* withdraw */ = { + isa = PBXGroup; + children = ( + DCBA3A5D2E60F191000BC8D3 /* CardRequest.swift */, + DCE8EC5A2E79E5860091B6F0 /* CardResponse.swift */, + DCBA3A5E2E60F191000BC8D3 /* WithdrawRequest.swift */, + ); + path = withdraw; + sourceTree = ""; + }; + DCBA3AB62E61FE26000BC8D3 /* bolt card */ = { + isa = PBXGroup; + children = ( + DCBA3AA62E61FE26000BC8D3 /* ArchiveCardSheet.swift */, + DCBA3AA72E61FE26000BC8D3 /* BoltCardInput.swift */, + DCBA3AA82E61FE26000BC8D3 /* BoltCardsHelp.swift */, + DCBA3AAA2E61FE26000BC8D3 /* CardOptionsSheet.swift */, + DCBA3AAB2E61FE26000BC8D3 /* DeleteCardSheet.swift */, + DCBA3AAC2E61FE26000BC8D3 /* LnurlwRegistration.swift */, + DCBA3AAD2E61FE26000BC8D3 /* ManageBoltCard.swift */, + DCBA3AAE2E61FE26000BC8D3 /* NewCardSheet.swift */, + DCBA3AAF2E61FE26000BC8D3 /* PrerequisitesSheet.swift */, + DCBA3AB02E61FE26000BC8D3 /* ReadCardSheet.swift */, + DCBA3AB12E61FE26000BC8D3 /* ResetCardSheet.swift */, + DCBA3AB22E61FE26000BC8D3 /* ResetSuccessSheet.swift */, + DCBA3AB32E61FE26000BC8D3 /* SimulatorPasteSheet.swift */, + DCBA3AB42E61FE26000BC8D3 /* SimulatorWriteSheet.swift */, + DCBA3AB52E61FE26000BC8D3 /* WriteErrorSheet.swift */, + ); + path = "bolt card"; + sourceTree = ""; + }; DCC4F30C2E4B9D9A00060704 /* notifications */ = { isa = PBXGroup; children = ( DC3521022E71F8A500DAB4F3 /* FcmPushNotification.swift */, + DCBA3A592E60F0EC000BC8D3 /* LnurlWithdrawNotification.swift */, DCC4F30D2E4B9DA900060704 /* PushNotification.swift */, ); path = notifications; @@ -1720,11 +1799,12 @@ DCE7232F27B167240017CF56 /* SyncSeedManager_Actor.swift */, DCAEF8D8275E69B000015993 /* SyncSeedManager_State.swift */, DCC9D999267BD28600EA36DD /* SyncBackupManager.swift */, + DCBA3AA22E61F1ED000BC8D3 /* SyncBackupManager+Cards.swift */, DCA7263C2C80BC0800600716 /* SyncBackupManager+Contacts.swift */, DCA7263A2C80BA0E00600716 /* SyncBackupManager+Payments.swift */, DCE7232D27AD68CD0017CF56 /* SyncBackupManager_Actor.swift */, - DCB493CE269F859E001B0F09 /* SyncBackupManager_State.swift */, DCB493D0269F890D001B0F09 /* SyncBackupManager_PendingSettings.swift */, + DCB493CE269F859E001B0F09 /* SyncBackupManager_State.swift */, ); path = sync; sourceTree = ""; @@ -1778,10 +1858,12 @@ DCD1BFD42CE775CE00C2B811 /* nfc */ = { isa = PBXGroup; children = ( - DC08FC712DAEAA790003E88C /* extensions */, - DC08FC6F2DAEAA5E0003E88C /* tools */, + DCBA3A6D2E6100A9000BC8D3 /* BoltCardScan.swift */, DC08FC2E2DA9796A0003E88C /* HceWriter.swift */, DCD1BFD52CE775E500C2B811 /* NfcReader.swift */, + DCBA3A6E2E6100A9000BC8D3 /* NFCReaderError+Ignore.swift */, + DCBA3A6F2E6100A9000BC8D3 /* NfcWriter.swift */, + DC08FC2A2DA978940003E88C /* CommandAPDU.swift */, ); path = nfc; sourceTree = ""; @@ -1888,6 +1970,9 @@ DC26D0BA2A93BD0F006763B3 /* EffectsLibrary */, DCCFE6AA2B6430AB002FFF11 /* Logging */, DC9B15432B7D0DCB0023743B /* AsyncAlgorithms */, + DCBA3CCD2E6F6427000BC8D3 /* DnaCommunicator */, + DCFB00E72E6F6488002F5282 /* DnaCommunicator */, + DC97012F2E74C29D000B1E59 /* DnaCommunicator */, ); productName = "phoenix-ios"; productReference = 7555FF7B242A565900829871 /* Phoenix.app */; @@ -1945,6 +2030,7 @@ name = "phoenix-notifySrvExt"; packageProductDependencies = ( DCCFE6B62B682E22002FFF11 /* Logging */, + DC9701312E74C2F6000B1E59 /* DnaCommunicator */, ); productName = "phoenix-notifySrvExt"; productReference = DCB511C7281AED58001BC525 /* phoenix-notifySrvExt.appex */; @@ -1999,6 +2085,7 @@ DC26D0B82A93BA8C006763B3 /* XCRemoteSwiftPackageReference "effects-library" */, DCCFE6A92B6430AB002FFF11 /* XCRemoteSwiftPackageReference "swift-log" */, DC9B15422B7D0DCB0023743B /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + DC97012E2E74C29D000B1E59 /* XCRemoteSwiftPackageReference "DnaCommunicator" */, ); productRefGroup = 7555FF7C242A565900829871 /* Products */; projectDirPath = ""; @@ -2153,6 +2240,21 @@ 7555FF7F242A565900829871 /* AppDelegate.swift in Sources */, DC5F96AE2E032C500052CDE6 /* UserDefaults+Dump.swift in Sources */, DC9933342CC0426300EB3100 /* AddContactOptionsSheet.swift in Sources */, + DCBA3AB72E61FE26000BC8D3 /* ResetSuccessSheet.swift in Sources */, + DCBA3AB82E61FE26000BC8D3 /* WriteErrorSheet.swift in Sources */, + DCBA3AB92E61FE26000BC8D3 /* PrerequisitesSheet.swift in Sources */, + DCBA3ABA2E61FE26000BC8D3 /* ManageBoltCard.swift in Sources */, + DCBA3ABB2E61FE26000BC8D3 /* ReadCardSheet.swift in Sources */, + DCBA3ABC2E61FE26000BC8D3 /* NewCardSheet.swift in Sources */, + DCBA3ABD2E61FE26000BC8D3 /* BoltCardsHelp.swift in Sources */, + DCBA3ABE2E61FE26000BC8D3 /* DeleteCardSheet.swift in Sources */, + DCBA3ABF2E61FE26000BC8D3 /* SimulatorWriteSheet.swift in Sources */, + DCBA3AC12E61FE26000BC8D3 /* SimulatorPasteSheet.swift in Sources */, + DCBA3AC22E61FE26000BC8D3 /* CardOptionsSheet.swift in Sources */, + DCBA3AC32E61FE26000BC8D3 /* BoltCardInput.swift in Sources */, + DCBA3AC42E61FE26000BC8D3 /* ResetCardSheet.swift in Sources */, + DCBA3AC52E61FE26000BC8D3 /* LnurlwRegistration.swift in Sources */, + DCBA3AC62E61FE26000BC8D3 /* ArchiveCardSheet.swift in Sources */, DC74174B270F332700F7E3E3 /* KotlinTypes.swift in Sources */, DC142135261E72320075857A /* AboutHTML.swift in Sources */, DC9CF83D2D2C6D37003F3B0F /* ScrollView_18.swift in Sources */, @@ -2191,8 +2293,8 @@ DC63BDF929AEB8180067A361 /* BackgroundPaymentsSelector.swift in Sources */, DCB30E542A0AABAF00E7D7A2 /* InfoPopoverWindow.swift in Sources */, DCA6DEC62829BDEB0073C658 /* XPC.swift in Sources */, - DC13A44E2E902F9200E8523F /* AppIdentifier.swift in Sources */, DC682FE8258175CE00CA1114 /* Popover.swift in Sources */, + DCBA3A5B2E60F0EC000BC8D3 /* LnurlWithdrawNotification.swift in Sources */, DC808E9D2D554A430019AE30 /* DisablingTorSheet.swift in Sources */, DC1844032A2690BB004D9578 /* MinerFeeSheet.swift in Sources */, DC39A2662A12C04D00F59E39 /* LiquidityPolicyHelp.swift in Sources */, @@ -2203,10 +2305,8 @@ DC370A892B7FBD7C0093C56F /* BtcAddrOptionsSheet.swift in Sources */, DC5F97162E2FEDA30052CDE6 /* WalletInfoSend.swift in Sources */, DC27E4CB2791D17A00C777CC /* RecoveryPhraseView.swift in Sources */, - DC08FC732DAEABC20003E88C /* ByteArrayConversions.swift in Sources */, DCB410892902D5BF00CE4FF9 /* PaymentsSection.swift in Sources */, DCACF6FC2566D0BA0009B01E /* Keychain.swift in Sources */, - DC08FC232DA978050003E88C /* CapabilitiesContainer.swift in Sources */, DCCD045D27EE0173007D57A5 /* EditInfoView.swift in Sources */, DC5F96BD2E09D6CB0052CDE6 /* Keychain+Wallet.swift in Sources */, DC2DC86A2906AC620079E570 /* FiatCurrencySelector.swift in Sources */, @@ -2329,6 +2429,7 @@ DCFA8759260E6F2E00AE8953 /* IntroView.swift in Sources */, C8D7A84CCF914B08BDB03BE6 /* ManualRestoreView.swift in Sources */, DCCD046327EE04E1007D57A5 /* WalletPaymentExtensions.swift in Sources */, + DC87582A2E81B01000A166EB /* Experimental.swift in Sources */, DC9B8EE225D72CC200E13818 /* ForceCloseChannelsView.swift in Sources */, 53BEFFEBEA9EBE7B27B53AF4 /* PaymentView.swift in Sources */, C8D7A74B29EAFF2EBD73BC6B /* ConfigurationView.swift in Sources */, @@ -2364,6 +2465,9 @@ DC5895D02E57856D00CBCCF9 /* TorNetworkIssueSheet.swift in Sources */, C8D7AFF5BC5754DBBEEB2688 /* ElectrumConfigurationView.swift in Sources */, DC2210D92DE4F7E30096D8D2 /* LabelAlignment.swift in Sources */, + DCBA3A712E6100A9000BC8D3 /* NFCReaderError+Ignore.swift in Sources */, + DCBA3A722E6100A9000BC8D3 /* NfcWriter.swift in Sources */, + DCBA3A732E6100A9000BC8D3 /* BoltCardScan.swift in Sources */, DC99E94025BA141000FB20F7 /* LocalWebView.swift in Sources */, 53BEFA633D95514CA5C0422A /* ChannelsConfigurationView.swift in Sources */, DC5F96CA2E09F3050052CDE6 /* Prefs+Debug.swift in Sources */, @@ -2393,7 +2497,7 @@ DCB04685260D162C007FDA37 /* ViewName.swift in Sources */, DC4CF3CE2BE96C36003A957F /* DisablePinView.swift in Sources */, DC4CBC822D6619B700129BDB /* Details_Incoming_Bolt12.swift in Sources */, - DC08FC292DA978810003E88C /* Array+Read.swift in Sources */, + DCE8EC5C2E79E58A0091B6F0 /* CardResponse.swift in Sources */, DCB30E522A0A948000E7D7A2 /* WalletInfoView.swift in Sources */, DCFA876D260E91E600AE8953 /* IntroContainer.swift in Sources */, DCA5391C29F7202F001BD3D5 /* ChannelInfoPopup.swift in Sources */, @@ -2409,6 +2513,8 @@ DC2B74C32C121571001BA1FD /* Bolt12Sheet.swift in Sources */, DC0D2EA72939273B00284608 /* KotlinExtensions+Payments.swift in Sources */, DC72C33925A663CA008A927A /* QRCode.swift in Sources */, + DCBA3A602E60F191000BC8D3 /* CardRequest.swift in Sources */, + DCBA3A612E60F191000BC8D3 /* WithdrawRequest.swift in Sources */, DC20D77A2BC5982400C37255 /* ServerMessageMonitor.swift in Sources */, DC5CA4ED28F83C3B0048A737 /* DrainWalletView.swift in Sources */, DC09FC3F2D5BADB900E6579A /* RestartPopover.swift in Sources */, @@ -2419,16 +2525,15 @@ DC6D26E329E76557006A7814 /* AnimatedClock.swift in Sources */, DC72CEF52C99DCEB00C810A8 /* LnurlFlowErrorNotice.swift in Sources */, DC4CBC8A2D6677A900129BDB /* Details_Incoming_SpliceIn.swift in Sources */, - DC5631C52C541E5C00DCB5BF /* Experimental.swift in Sources */, + DCBA3AA32E61F1ED000BC8D3 /* SyncBackupManager+Cards.swift in Sources */, DCF8E3AC2BC4968D009299EE /* LowMinerFeeWarning.swift in Sources */, - DC13A4502E902FBC00E8523F /* AppIdentifier+Foreground.swift in Sources */, DC808E9B2D5541F50019AE30 /* UsingTorSheet.swift in Sources */, DC65D86428E2F7D700686355 /* ResetWalletView_Action.swift in Sources */, DC71E7332728645B0063613D /* CurrencyConverterView.swift in Sources */, DCA3B41F2A5471C900E6B231 /* MinerFeeInfo.swift in Sources */, DC6F04232C35EB9900627B4F /* SummaryInfoGrid.swift in Sources */, DC5F96F32E1D76F00052CDE6 /* WalletMetadataView.swift in Sources */, - DCACF6F02566D0A60009B01E /* Data+Hexadecimal.swift in Sources */, + DCACF6F02566D0A60009B01E /* Digest+Hexadecimal.swift in Sources */, DCE3C7AB2A6AD3CC00F4D385 /* MempoolMonitor.swift in Sources */, DC3780392C04D60400937C8E /* KotlinEnums.swift in Sources */, DC5F971A2E32E4930052CDE6 /* GlobalEnvironmentView.swift in Sources */, @@ -2446,7 +2551,6 @@ DC2F431A27B699800006FCC4 /* ModifyInvoiceSheet.swift in Sources */, DCDAA7402971C29700B406A8 /* RecentPaymentsSelector.swift in Sources */, DC2ABAD72BED081400C11C9C /* VibrationFeedback.swift in Sources */, - DC08FC6E2DA9ABE80003E88C /* Ndef.swift in Sources */, DCD5FF4326A0D34B009CC666 /* EqualSizes.swift in Sources */, DC6F04272C3895E300627B4F /* ContactPhoto.swift in Sources */, DC4CBC9C2D68E65500129BDB /* Details_Outgoing_SpliceCpfp.swift in Sources */, @@ -2463,8 +2567,11 @@ DCEAE5B92943D64B00320C46 /* MsatRange.swift in Sources */, DC43096E2A7953F400E28995 /* FinalWalletDetails.swift in Sources */, DC74174D270F455D00F7E3E3 /* AES256.swift in Sources */, + DCBA3AA52E61F3BB000BC8D3 /* KotlinExtensions+Cards.swift in Sources */, DC5895CD2E55217400CBCCF9 /* Task+Cancellable.swift in Sources */, DC4CBC942D678ED700129BDB /* Details_Outgoing_Lightning.swift in Sources */, + DCBA3A672E60FFC5000BC8D3 /* AppIdentifier.swift in Sources */, + DCBA3A682E60FFC5000BC8D3 /* AppIdentifier+Foreground.swift in Sources */, C8D7AB09E80CE2B6AE270A97 /* TorConfigurationView.swift in Sources */, DC27E4C42791C58C00C777CC /* PaymentsBackupView.swift in Sources */, DC59377127516297003B4B53 /* Sequence+Sum.swift in Sources */, @@ -2501,18 +2608,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DCE8EC5B2E79E58A0091B6F0 /* CardResponse.swift in Sources */, + DCBA3A622E60F191000BC8D3 /* CardRequest.swift in Sources */, + DCBA3A632E60F191000BC8D3 /* WithdrawRequest.swift in Sources */, DC641C752821706600862DCD /* Currency.swift in Sources */, DC641C7228208B4D00862DCD /* GroupPrefs.swift in Sources */, - DC635AA42E4264BF0038ED92 /* Data+Hexadecimal.swift in Sources */, + DC635AA42E4264BF0038ED92 /* Digest+Hexadecimal.swift in Sources */, DC78A5F92DF22ABE003A7DD8 /* NotificationServiceQueue.swift in Sources */, DCEB2798282D7B070096B87E /* KotlinExtensions+Other.swift in Sources */, DC641C7328208B7F00862DCD /* UserDefaults+Codable.swift in Sources */, DC5895CE2E55219800CBCCF9 /* Task+Cancellable.swift in Sources */, DC5F96B82E0455760052CDE6 /* GroupPrefs+Global.swift in Sources */, - DC13A4542E902FF300E8523F /* AppIdentifier+Background.swift in Sources */, DCEB2796282D7AAB0096B87E /* KotlinTypes.swift in Sources */, DC9130A02AE045FA00F9B8C6 /* Sequence+Sum.swift in Sources */, DCCFE6C02B713FBE002FFF11 /* LogFileHandler.swift in Sources */, + DCBA3ACA2E6606DC000BC8D3 /* KotlinExtensions+Cards.swift in Sources */, DCCFE6B42B680DF9002FFF11 /* OSLogHandler.swift in Sources */, DC5F96AF2E032EFF0052CDE6 /* UserDefaults+Dump.swift in Sources */, DCB511CA281AED58001BC525 /* NotificationService.swift in Sources */, @@ -2530,14 +2640,16 @@ DC5F96A52E00A6B30052CDE6 /* WalletIdentifier.swift in Sources */, DC641C77282171D200862DCD /* KotlinExtensions+Currency.swift in Sources */, DC78A6022DF8C18F003A7DD8 /* UserDefaults+DefaultValue.swift in Sources */, + DCBA3A6A2E60FFC5000BC8D3 /* AppIdentifier.swift in Sources */, + DCBA3A6C2E60FFC5000BC8D3 /* AppIdentifier+Background.swift in Sources */, DC641C762821716A00862DCD /* UserDefaults+Serialization.swift in Sources */, DCCFE6BF2B713FBB002FFF11 /* LogFileManager.swift in Sources */, DCF9CFD52862656E001AD33F /* Asserts.swift in Sources */, DC641C7C282172BB00862DCD /* DelayedSave.swift in Sources */, + DCBA3A5A2E60F0EC000BC8D3 /* LnurlWithdrawNotification.swift in Sources */, DCA6DECE282AB12B0073C658 /* SecurityFile.swift in Sources */, DC3521042E71F8AD00DAB4F3 /* FcmPushNotification.swift in Sources */, DCCFE6BE2B713FB8002FFF11 /* LogFileInfo.swift in Sources */, - DC13A4512E902FD300E8523F /* AppIdentifier.swift in Sources */, DC49FE9C2AC49E0400D8D2E2 /* KotlinExtensions+Lightning.swift in Sources */, DC5F96F82E1DA3D60052CDE6 /* PinType.swift in Sources */, DCC4F30F2E4B9E1500060704 /* PushNotification.swift in Sources */, @@ -2554,6 +2666,7 @@ DC5895CB2E551A7800CBCCF9 /* AnyAsyncSequence.swift in Sources */, DC5F96BA2E0457530052CDE6 /* Prefs+Constants.swift in Sources */, DCCFE6B32B680DF5002FFF11 /* LoggerFactory.swift in Sources */, + DCBA3ACB2E6609BC000BC8D3 /* KotlinEnums.swift in Sources */, DC1B71C62CCBE9A400914D80 /* AddToContactsInfo.swift in Sources */, DCA6DECA2829C31B0073C658 /* KeyStoreError.swift in Sources */, DC49FE9D2AC49E0800D8D2E2 /* KotlinExtensions+Bitcoin.swift in Sources */, @@ -3000,6 +3113,14 @@ version = 9.1.0; }; }; + DC97012E2E74C29D000B1E59 /* XCRemoteSwiftPackageReference "DnaCommunicator" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ACINQ/DnaCommunicator/"; + requirement = { + kind = exactVersion; + version = 1.0.1; + }; + }; DC9B15422B7D0DCB0023743B /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; @@ -3047,6 +3168,16 @@ package = DC72C2EA25A3CADC008A927A /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseMessaging; }; + DC97012F2E74C29D000B1E59 /* DnaCommunicator */ = { + isa = XCSwiftPackageProductDependency; + package = DC97012E2E74C29D000B1E59 /* XCRemoteSwiftPackageReference "DnaCommunicator" */; + productName = DnaCommunicator; + }; + DC9701312E74C2F6000B1E59 /* DnaCommunicator */ = { + isa = XCSwiftPackageProductDependency; + package = DC97012E2E74C29D000B1E59 /* XCRemoteSwiftPackageReference "DnaCommunicator" */; + productName = DnaCommunicator; + }; DC9B15432B7D0DCB0023743B /* AsyncAlgorithms */ = { isa = XCSwiftPackageProductDependency; package = DC9B15422B7D0DCB0023743B /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; @@ -3057,6 +3188,10 @@ package = DCA5391729F1DAA3001BD3D5 /* XCRemoteSwiftPackageReference "SwiftySegmentedPicker" */; productName = SegmentedPicker; }; + DCBA3CCD2E6F6427000BC8D3 /* DnaCommunicator */ = { + isa = XCSwiftPackageProductDependency; + productName = DnaCommunicator; + }; DCCFE6AA2B6430AB002FFF11 /* Logging */ = { isa = XCSwiftPackageProductDependency; package = DCCFE6A92B6430AB002FFF11 /* XCRemoteSwiftPackageReference "swift-log" */; @@ -3067,6 +3202,10 @@ package = DCCFE6A92B6430AB002FFF11 /* XCRemoteSwiftPackageReference "swift-log" */; productName = Logging; }; + DCFB00E72E6F6488002F5282 /* DnaCommunicator */ = { + isa = XCSwiftPackageProductDependency; + productName = DnaCommunicator; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 7555FF73242A565900829871 /* Project object */; diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/phoenix-ios/phoenix-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a05935be..549ebf99e 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "653895f3cf5de8736e109a9ddd2552da4dbecc49c096d31ff1dcbb9c5314a3b4", + "originHash" : "8748739a34c7f198eabeaf2257b7dc847a116026ae29688e1c68721fe9db7b7f", "pins" : [ { "identity" : "abseil-cpp-swiftpm", @@ -28,6 +28,15 @@ "version" : "0.4.1" } }, + { + "identity" : "dnacommunicator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ACINQ/DnaCommunicator/", + "state" : { + "revision" : "be4589e9403f67fc52fce0872ca7a2712f286c5b", + "version" : "1.0.1" + } + }, { "identity" : "effects-library", "kind" : "remoteSourceControl", diff --git a/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/Contents.json b/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/Contents.json new file mode 100644 index 000000000..32dfe5551 --- /dev/null +++ b/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "boltcard.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/boltcard.svg b/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/boltcard.svg new file mode 100644 index 000000000..89d8d6851 --- /dev/null +++ b/phoenix-ios/phoenix-ios/Assets.xcassets/boltcard.imageset/boltcard.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/phoenix-ios/phoenix-ios/Info.plist b/phoenix-ios/phoenix-ios/Info.plist index 094d2846f..67997a4e8 100644 --- a/phoenix-ios/phoenix-ios/Info.plist +++ b/phoenix-ios/phoenix-ios/Info.plist @@ -2,6 +2,10 @@ + com.apple.developer.nfc.readersession.iso7816.select-identifiers + + D2760000850101 + BGTaskSchedulerPermittedIdentifiers co.acinq.phoenix.WatchTower diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index b421d2f6a..ab3d3263f 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -40,6 +40,21 @@ } } } + }, + " - Counter:" : { + + }, + " - Name:" : { + + }, + " - Status:" : { + + }, + " - UID:" : { + + }, + " - Verified:" : { + }, " (not available)" : { "localizations" : { @@ -245,6 +260,30 @@ }, " [more info](https://phoenix.acinq.co/faq#what-is-inbound-liquidity)" : { + }, + " • [Bolt Ring](https://bitcoin-ring.com/)" : { + + }, + " • [CoinCorner.com](https://www.coincorner.com/BuyTheBoltCard)" : { + + }, + " • [Hirsch](https://shop.hirschsecure.com/products/printed-nxp-ntag-424-dna-tag-5-pack)" : { + + }, + " • [Laser Eyes Cards](https://lasereyes.cards/)" : { + + }, + " • [NFC.cards](https://nfc.cards/en/white-cards/46-nfc-card-ntag424-dna.html)" : { + + }, + " • [PlebTag](https://plebtag.com/)" : { + + }, + " • [Yanabu Bolt Card - Korea](https://marpple.shop/kr/yanabu/products/13356281)" : { + + }, + " • [ZipNFC.com](https://zipnfc.com/nfc-pvc-card-credit-card-size-ntag424-dna.html)" : { + }, "-" : { "extractionState" : "manual", @@ -951,6 +990,9 @@ }, "(wait a few seconds and then retry)" : { + }, + "**Daily** spending limit:" : { + }, "**disabled**: sent payments will be anonymous" : { "localizations" : { @@ -1153,6 +1195,9 @@ } } } + }, + "**Monthly** spending limit:" : { + }, "**This Bolt12 payment request is the Lightning equivalent to a Bitcoin address.**\n\nUnlike traditional Lightning invoices, it does not expire and can be reused.\n\nShare it widely : for donations, tips, or to get paid by your friends.\n\n🛟 **Who supports Bolt12?**\n\nFor the moment, few services support Bolt12. If a service rejects this invoice, let them know they're missing out!\n\n🪫 **Restrictions**\n\nYour phone must be turned on and connected to the internet to receive a payment. Also enabling TOR in Phoenix may cause issues." : { "localizations" : { @@ -4117,6 +4162,9 @@ } } } + }, + "A sheet will appear to guide you through the process" : { + }, "A temporary error occurred, and Phoenix is unable to continue loading." : { "localizations" : { @@ -4483,6 +4531,9 @@ }, "Access hidden wallet" : { + }, + "Active" : { + }, "active commitments" : { "localizations" : { @@ -4974,6 +5025,12 @@ } } } + }, + "Afterwards, the card will be Archived, and can never be activated again. The card will remain in your list, but will be moved to the Archived section." : { + + }, + "All payment attempts will be rejected." : { + }, "All payment channels will be closed." : { "localizations" : { @@ -5703,6 +5760,9 @@ } } } + }, + "An active card can be used for payments." : { + }, "An error occurred on a node in the payment route. The payment may succeed if you try again." : { "localizations" : { @@ -5743,6 +5803,12 @@ } } } + }, + "An error occurred while attempting to write to the NFC tag." : { + + }, + "An NFC session is already running." : { + }, "An on-chain operation will be likely required for you to receive this amount.\n\nThe fee is estimated to be around %@." : { "localizations" : { @@ -5863,6 +5929,9 @@ } } } + }, + "An unexpected error occurred while attempting to reset the card. Please try resetting it again. If the problem persists, you may need to destroy the card by cutting it up." : { + }, "An unknown error has occurred." : { "extractionState" : "stale", @@ -5944,6 +6013,9 @@ } } } + }, + "An unknown error occurred while attempting to clear the keys from the card. Please try resetting it again. If the problem persists, you may need to destroy the card by cutting it up." : { + }, "An unknown error occurred." : { "comment" : "error details", @@ -6067,6 +6139,9 @@ } } } + }, + "Any payments made with this card will remain in your transaction history, but will no longer be linked with any card." : { + }, "App access" : { "localizations" : { @@ -6269,6 +6344,21 @@ } } } + }, + "Archive" : { + + }, + "Archive card" : { + + }, + "Archive card…" : { + + }, + "Archived Cards" : { + + }, + "Are you sure you want to delete this card?" : { + }, "Are you sure you want to pay this invoice?" : { "extractionState" : "manual", @@ -6916,6 +7006,9 @@ } } } + }, + "Awaiting payment…" : { + }, "Back" : { "localizations" : { @@ -7439,6 +7532,9 @@ } } } + }, + "Be your own bank" : { + }, "Below the expected fee. Some payments may be rejected." : { "localizations" : { @@ -8005,6 +8101,9 @@ } } } + }, + "Bitcoin payments over the lightning network with a contactless payment card." : { + }, "Bitcoin supercharged" : { "localizations" : { @@ -8207,6 +8306,18 @@ } } } + }, + "Bolt Card" : { + + }, + "Bolt Card not detected in NFC tag" : { + + }, + "Bolt Card:" : { + + }, + "Bolt Cards" : { + }, "Bolt12 invoice" : { "localizations" : { @@ -8736,6 +8847,37 @@ } } } + }, + "card" : { + "comment" : "button label - try to make it short" + }, + "Card" : { + + }, + "Card not associated with this wallet." : { + + }, + "Card options" : { + + }, + "Card payment" : { + + }, + "Card's wallet didn't respond to payment request." : { + + }, + "Card's wallet rejected payment request.\nError code: %lld\nMessage: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Card's wallet rejected payment request.\nError code: %1$lld\nMessage: %2$@" + } + } + } + }, + "Card's wallet rejected payment request.\nMessage: %@" : { + }, "Caused by" : { "localizations" : { @@ -10567,6 +10709,9 @@ }, "Communicating with card reader." : { + }, + "Communicating with card's host…" : { + }, "completed at" : { "comment" : "Label in DetailsView_IncomingPayment", @@ -11948,6 +12093,9 @@ } } } + }, + "Copy and paste into simulator:" : { + }, "Copy certificate" : { "localizations" : { @@ -12108,6 +12256,9 @@ } } } + }, + "Copy simulator's info:" : { + }, "Copy transaction id" : { "localizations" : { @@ -12189,6 +12340,15 @@ } } }, + "Could not authenticate with card." : { + + }, + "Could not communicate with card's wallet" : { + "comment" : "Error message - scanning lightning invoice" + }, + "Could not connect to card's host" : { + "comment" : "Error message - processing bolt card payment" + }, "Could not connect to host:" : { "localizations" : { "ar" : { @@ -12311,6 +12471,9 @@ } } } + }, + "Could not connect to the NFC tag." : { + }, "Could not retrieve payment details within a reasonable time. The recipient may be offline or unreachable." : { "localizations" : { @@ -12391,9 +12554,18 @@ } } } + }, + "Cound not communicate with card's host" : { + + }, + "Cound not communicate with card's wallet" : { + }, "Create new contact" : { + }, + "Create New Debit Card" : { + }, "Create new wallet" : { "localizations" : { @@ -13368,6 +13540,12 @@ } } } + }, + "Delete card" : { + + }, + "Delete card…" : { + }, "Delete contact" : { "localizations" : { @@ -14413,6 +14591,9 @@ } } } + }, + "Details: %@" : { + }, "Disable Lock PIN" : { "comment" : "Navigation bar title" @@ -15062,6 +15243,9 @@ } } } + }, + "Does not appear to be a bolt card." : { + }, "Don't lose your funds:" : { "localizations" : { @@ -16417,6 +16601,9 @@ } } } + }, + "Error fetching registration. Please check internet connection." : { + }, "Error fetching wallet backups from iCloud" : { "localizations" : { @@ -18810,6 +18997,12 @@ } } }, + "Frozen" : { + + }, + "Frozen (archived)" : { + "comment" : "translate: archived" + }, "full payment history" : { "localizations" : { "ar" : { @@ -19291,6 +19484,9 @@ } } } + }, + "Get address" : { + }, "Get started" : { "localizations" : { @@ -19374,6 +19570,9 @@ }, "Go back to visible wallets list" : { + }, + "Go to: Configuration > Bolt cards" : { + }, "Good news! Your transaction has been mined!" : { "localizations" : { @@ -19622,6 +19821,12 @@ } } }, + "Hold your card near the device to program it." : { + "comment" : "Message in iOS NFC dialog" + }, + "Hold your card near the device to reset it." : { + "comment" : "Message in iOS NFC dialog" + }, "Hold your device near the reader." : { }, @@ -19916,6 +20121,9 @@ } } } + }, + "How does Bolt card work ?" : { + }, "How it works" : { "localizations" : { @@ -20979,6 +21187,9 @@ } } } + }, + "If you still have access to the physical card, it's recommended that you **reset** the card first. This will allow it to be linked again with any wallet." : { + }, "If you switch to a new device (or reinstall the app) then you'll lose this information." : { "localizations" : { @@ -23283,6 +23494,9 @@ } } } + }, + "It can be linked again with any wallet." : { + }, "just now" : { "comment" : "Timestamp for notification", @@ -23446,6 +23660,9 @@ } } } + }, + "Key slots unavailable" : { + }, "know when fees may occur" : { "localizations" : { @@ -23611,6 +23828,9 @@ } } } + }, + "learn more" : { + }, "Learn more" : { "localizations" : { @@ -24441,6 +24661,12 @@ } } } + }, + "Limit applies from 1st of the month at midnight to the following 1st (local time)." : { + + }, + "Limit applies from midnight to midnight (local time)." : { + }, "Link" : { "comment" : "lnurl-auth: login button title", @@ -24482,6 +24708,12 @@ } } } + }, + "Link a **physical card** to your Phoenix wallet." : { + + }, + "Link a card to a simulator wallet for testing." : { + }, "Linked" : { "comment" : "lnurl-auth: success text", @@ -24523,6 +24755,9 @@ } } } + }, + "Linked Cards" : { + }, "Liquidity" : { @@ -25225,9 +25460,15 @@ } } } + }, + "Manage Card" : { + }, "Manage or switch wallets" : { + }, + "Management Tasks" : { + }, "Manual Backup" : { "comment" : "Navigation bar title", @@ -25962,6 +26203,9 @@ } } } + }, + "Message Authentication Code:" : { + }, "Metadata" : { "localizations" : { @@ -26576,6 +26820,9 @@ } } }, + "My Bolt Card" : { + "comment" : "Default name for a bolt card when creating a new one" + }, "N/A" : { "localizations" : { "ar" : { @@ -27027,6 +27274,15 @@ }, "NFC capabilities not available on this device" : { + }, + "NFC capabilities not available on this device." : { + + }, + "NFC error: %@" : { + + }, + "NFC process terminated unexpectedly" : { + }, "NFC reader is already scanning" : { @@ -27723,6 +27979,9 @@ } } } + }, + "Note that simulators do not support background execution" : { + }, "Note: Server must have a certificate" : { "localizations" : { @@ -28089,6 +28348,9 @@ } } } + }, + "On a real device:" : { + }, "on-chain <-> lightning" : { "localizations" : { @@ -28417,6 +28679,9 @@ } } } + }, + "Once a card is archived it can never be activated again. The card will remain in your list, but will be moved to the Archived section." : { + }, "Only show in-flight outgoing payments." : { "comment" : "Recent payments option", @@ -28622,6 +28887,9 @@ }, "Open Orbot website" : { + }, + "Open Phoenix app (debug build)" : { + }, "Open Tor settings" : { @@ -29115,6 +29383,12 @@ } } } + }, + "Paste here" : { + + }, + "Paste JSON output from device here" : { + }, "Path" : { "extractionState" : "manual", @@ -31133,6 +31407,9 @@ } } } + }, + "Picc Data:" : { + }, "PIN" : { "extractionState" : "stale", @@ -31657,6 +31934,9 @@ } } } + }, + "Please try again. And be sure to hold the card close to the phone until the writing process completes." : { + }, "plus hidden amount incoming" : { "localizations" : { @@ -31857,6 +32137,15 @@ } } } + }, + "Preparing system for NFC..." : { + + }, + "Prerequisites" : { + + }, + "Press and hold \"create new debit card\" button for 3 seconds" : { + }, "Privacy" : { "comment" : "Navigation bar title", @@ -31979,6 +32268,9 @@ } } } + }, + "Protocol error: %@" : { + }, "Public key" : { "localizations" : { @@ -32384,6 +32676,15 @@ } } } + }, + "Read card" : { + + }, + "Read card…" : { + + }, + "Reading card…" : { + }, "Ready For Swap" : { "localizations" : { @@ -33151,6 +33452,12 @@ } } } + }, + "Remaining" : { + + }, + "Remember:" : { + }, "Remote" : { "localizations" : { @@ -33231,6 +33538,9 @@ } } } + }, + "Requesting payment…" : { + }, "Requesting Swap-In Address..." : { "extractionState" : "manual", @@ -33272,6 +33582,15 @@ } } } + }, + "Reset" : { + + }, + "Reset card" : { + + }, + "Reset physical card…" : { + }, "Reset user preferences" : { "localizations" : { @@ -34165,6 +34484,9 @@ } } } + }, + "Scanned NDEF tag with unknown type" : { + }, "Search" : { "localizations" : { @@ -37026,6 +37348,15 @@ } } } + }, + "Simulator debugging" : { + + }, + "Simulator Info:" : { + + }, + "Simulator instructions" : { + }, "Since you've enabled Tor, you should use an onion address for this server." : { "localizations" : { @@ -37189,6 +37520,9 @@ } } } + }, + "So to make a payment using the card, the simulator must be open, with Phoenix running in the foreground" : { + }, "Something is amiss with this invoice..." : { "extractionState" : "manual", @@ -37353,9 +37687,15 @@ }, "Spending Control" : { + }, + "Spending Limits" : { + }, "Spending PIN" : { + }, + "Spent" : { + }, "Splice already in progress" : { "localizations" : { @@ -37757,6 +38097,12 @@ } } } + }, + "Status: Active" : { + + }, + "Status: Frozen" : { + }, "Step #%lld" : { "localizations" : { @@ -38692,6 +39038,15 @@ } } }, + "The card is **NOT** ready to be used. Please try writing it again." : { + + }, + "The card's host returned error code: %d" : { + "comment" : "Error message - processing bolt card payment" + }, + "The card's host returned error message: %@" : { + "comment" : "Error message - processing bolt card payment" + }, "The closing transaction is in your transactions list." : { "comment" : "label text", "localizations" : { @@ -39446,6 +39801,9 @@ } } } + }, + "The merchant can use these values to make a one time request to your wallet. After that, the card must be tapped again to get fresh values." : { + }, "The migration will cost %@ (≈ %@)" : { "localizations" : { @@ -39492,6 +39850,9 @@ } } } + }, + "The NFC card is programmed with a BLIP XX address and a set of secure keys. The card then produces the address plus two unique hashes that change each time the card is scanned." : { + }, "The payment amount is invalid." : { "localizations" : { @@ -40150,6 +40511,12 @@ } } } + }, + "The simulator doesn't support NFC. But you can link a card to this wallet for testing." : { + + }, + "The simulator must be running on a Mac with either Apple Silicon or the T2 security chip (to receive push notifications)" : { + }, "The swap-in wallet is a bridge to Lightning. Funds on this wallet will automatically be moved to Lightning according to your liquidity policy setting." : { "localizations" : { @@ -40430,6 +40797,9 @@ } } } + }, + "Then make **contactless payments** at supporting merchants." : { + }, "These funds come from closed Lightning channels. They must be spent manually." : { "localizations" : { @@ -40756,6 +41126,12 @@ }, "This appears to be a website:" : { + }, + "This card has been improperly programmed or reset too many times, and it's now impossible to use the card." : { + + }, + "This card is already linked to another wallet. To re-use this card you must first unlink the card. In Phoenix there is an option called \"reset physical card\" which will unlink it." : { + }, "This doesn't appear to be a Lightning invoice" : { "comment" : "Error message - scanning lightning invoice", @@ -40797,6 +41173,9 @@ } } } + }, + "This doesn't appear to be the linked card. Perhaps this card is associated with a different wallet, or a different card in this wallet." : { + }, "This feature is not a \"fix everything magic button\". It is here as a safety measure and **should only be used in extreme scenarios**. For example, if your peer (ACINQ) disappears permanently, preventing you from spending your money. In all other cases, **if you experience issues with Phoenix you should contact support**." : { "comment" : "ForceCloseChannelsView", @@ -41689,6 +42068,12 @@ }, "This wallet is **hidden**, and a hidden wallet requires a Lock PIN to be set." : { + }, + "This will clear the card, allowing it to be linked again with any wallet." : { + + }, + "This will create a new card that is linked to a wallet running on a simulator" : { + }, "This will reset the app, as if you had just installed it." : { "localizations" : { @@ -42904,6 +43289,9 @@ } } } + }, + "True" : { + }, "Trust certificate & pin public key" : { "localizations" : { @@ -43757,6 +44145,9 @@ } } }, + "Unreadable response from card's host" : { + "comment" : "Error message - processing bolt card payment" + }, "Unreadable response from service: %@" : { "comment" : "Error message - scanning lightning invoice", "localizations" : { @@ -44321,6 +44712,12 @@ } } } + }, + "Use this option if your card is permanantly lost or stolen." : { + + }, + "Use this screen to manage your card anytime you need." : { + }, "Use this screen to spend funds from your final wallet. These funds come from channels that have been closed in the past. This does not affect your existing Lightning channels." : { "localizations" : { @@ -44562,6 +44959,15 @@ } } } + }, + "Version 1 (lnurl-withdraw)" : { + + }, + "Version 1 & 2 (lnurl-withdraw + v2 param)" : { + + }, + "Version 2 (bip-353 & onion messages)" : { + }, "Version 2.5.0: Tor changes" : { "localizations" : { @@ -46021,6 +46427,9 @@ } } } + }, + "What you need are blank NFC \"NTAG 424 DNA\" cards. You can buy them from many different vendors." : { + }, "What's new:" : { "localizations" : { @@ -46101,6 +46510,9 @@ } } } + }, + "Where can I buy bolt cards ?" : { + }, "Which PIN?" : { "localizations" : { @@ -46591,6 +47003,12 @@ } } } + }, + "Write card for simulator" : { + + }, + "Write error" : { + }, "Yes" : { "extractionState" : "manual", @@ -46960,6 +47378,9 @@ }, "You can enter the PIN for this wallet anytime in the lock screen, and Phoenix will **switch** to this wallet." : { + }, + "You can link multiple debit cards to your wallet. Set custom spending limits per card, and freeze a card at anytime." : { + }, "You can make all your unconfirmed transactions use a higher effective feerate to encourage miners to favour your payments." : { "localizations" : { @@ -47805,6 +48226,9 @@ } } } + }, + "You need a BIP-353 address before you can create a card." : { + }, "You need at least one channel to claim your address. Try adding funds to your wallet and try again." : { "localizations" : { @@ -48138,8 +48562,18 @@ }, "Your balance of:" : { + }, + "Your bank (this device) needs to be online to process payments with your card." : { + + }, + "Your card is now ready to use." : { + + }, + "Your card is now reset." : { + }, "Your channel are not connected yet. Wait for a stable connection and try again." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -48218,6 +48652,9 @@ } } } + }, + "Your channels are not connected yet. Wait for a stable connection and try again." : { + }, "Your comment will be sent when you pay." : { "localizations" : { @@ -48661,6 +49098,9 @@ } } } + }, + "Your wallet verifies the card is not frozen, and checks the payment amount against any daily/monthly spending limits you may have configured." : { + } }, "version" : "1.0" diff --git a/phoenix-ios/phoenix-ios/extensions/Digest+Hexadecimal.swift b/phoenix-ios/phoenix-ios/extensions/Digest+Hexadecimal.swift new file mode 100644 index 000000000..acfc84827 --- /dev/null +++ b/phoenix-ios/phoenix-ios/extensions/Digest+Hexadecimal.swift @@ -0,0 +1,10 @@ +import Foundation +import CryptoKit +import DnaCommunicator + +extension SHA256.Digest { + + func toHex(_ options: HexOptions = .lowerCase) -> String { + return self.map { String(format: options.formatString, $0) }.joined() + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinEnums.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinEnums.swift index 037ec03fa..3287fd36e 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinEnums.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinEnums.swift @@ -13,7 +13,7 @@ extension Lightning_kmpFinalFailure { } if let _ = self.asChannelNotConnected() { return String(localized: - "Your channel are not connected yet. Wait for a stable connection and try again.") + "Your channels are not connected yet. Wait for a stable connection and try again.") } if let _ = self.asChannelOpening() { return String(localized: diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Cards.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Cards.swift new file mode 100644 index 000000000..3f8ae59df --- /dev/null +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Cards.swift @@ -0,0 +1,104 @@ +import Foundation +import PhoenixShared +import DnaCommunicator + +extension BoltCardInfo { + + var sanitizedName: String { + let result = name.trimmingCharacters(in: .whitespacesAndNewlines) + return result.isEmpty ? BoltCardInfo.defaultName : result + } + + var isActive: Bool { + return !isFrozen + } + + var createdAtDate: Date { + return createdAt.toDate() + } + + func withUpdatedLastKnownCounter(_ counter: UInt32) -> BoltCardInfo { + return doCopy( + id : self.id, + name : self.name, + keys : self.keys, + uid : self.uid, + lastKnownCounter : counter, + isFrozen : self.isFrozen, + isArchived : self.isArchived, + isReset : self.isReset, + isForeign : self.isForeign, + dailyLimit : self.dailyLimit, + monthlyLimit : self.monthlyLimit, + createdAt : self.createdAt + ) + } + + func archivedCopy() -> BoltCardInfo { + return doCopy( + id : self.id, + name : self.name, + keys : self.keys, + uid : self.uid, + lastKnownCounter : self.lastKnownCounter, + isFrozen : true, // this should also be set (just to be careful) + isArchived : true, + isReset : self.isReset, + isForeign : self.isForeign, + dailyLimit : self.dailyLimit, + monthlyLimit : self.monthlyLimit, + createdAt : self.createdAt + ) + } + + func resetCopy() -> BoltCardInfo { + return doCopy( + id : self.id, + name : self.name, + keys : self.keys, + uid : self.uid, + lastKnownCounter : self.lastKnownCounter, + isFrozen : true, // this should also be set (just to be careful) + isArchived : true, // this must be set + isReset : true, + isForeign : self.isForeign, + dailyLimit : self.dailyLimit, + monthlyLimit : self.monthlyLimit, + createdAt : self.createdAt + ) + } + + static var defaultName: String { + return String( + localized: "My Bolt Card", + comment: "Default name for a bolt card when creating a new one" + ) + } +} + +extension BoltCardKeySet { + + var key0_data: Data { + return key0.toSwiftData() + } + + var key0_bytes: [UInt8] { + return key0_data.toByteArray() + } + + var piccDataKey_data: Data { + return piccDataKey.toSwiftData() + } + + var piccDataKey_bytes: [UInt8] { + return piccDataKey_data.toByteArray() + } + + var cmacKey_data: Data { + return cmacKey.toSwiftData() + } + + var cmacKey_bytes: [UInt8] { + return cmacKey_data.toByteArray() + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+CloudKit.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+CloudKit.swift index 7f7bf58b5..344d8d65b 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+CloudKit.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+CloudKit.swift @@ -1,6 +1,47 @@ import Foundation import PhoenixShared +struct FetchCardsQueueBatchResult { + let rowids: [Int64] + let rowidMap: [Int64: Lightning_kmpUUID] + let rowMap: [Lightning_kmpUUID : BoltCardInfo] + let metadataMap: [Lightning_kmpUUID : KotlinByteArray] + + func uniqueCardIds() -> Set { + return Set(rowidMap.values) + } + + func rowidsMatching(_ query: Lightning_kmpUUID) -> [Int64] { + var results = [Int64]() + for (rowid, cardId) in rowidMap { + if cardId == query { + results.append(rowid) + } + } + return results + } + + func rowidsMatching(_ query: String) -> [Int64] { + var results = [Int64]() + for (rowid, cardId) in rowidMap { + if cardId.id == query { + results.append(rowid) + } + } + return results + } + + static func empty() -> FetchCardsQueueBatchResult { + + return FetchCardsQueueBatchResult( + rowids: [], + rowidMap: [:], + rowMap: [:], + metadataMap: [:] + ) + } +} + struct FetchContactsQueueBatchResult { let rowids: [Int64] let rowidMap: [Int64: Lightning_kmpUUID] @@ -83,6 +124,43 @@ struct FetchPaymentsQueueBatchResult { } } +extension CloudKitCardsDb.FetchQueueBatchResult { + + func convertToSwift() -> FetchCardsQueueBatchResult { + + // We are experiencing crashes like this: + // + // for (rowid, paymentRowId) in batch.rowidMap { + // ^^^^^ + // Crash: Could not cast value of type '__NSCFNumber' to 'PhoenixSharedLong'. + // + // This appears to be some kind of bug in Kotlin. + // So we're going to make a clean migration. + // And we need to do so without swift-style enumeration in order to avoid crashing. + + var _rowids = [Int64]() + var _rowidMap = [Int64: Lightning_kmpUUID]() + + for i in 0 ..< self.rowids.count { // cannot enumerate self.rowidMap + + let value_kotlin = rowids[i] + let value_swift = value_kotlin.int64Value + + _rowids.append(value_swift) + if let cardId = self.rowidMap[value_kotlin] { + _rowidMap[value_swift] = cardId + } + } + + return FetchCardsQueueBatchResult( + rowids: _rowids, + rowidMap: _rowidMap, + rowMap: self.rowMap, + metadataMap: self.metadataMap + ) + } +} + extension CloudKitContactsDb.FetchQueueBatchResult { func convertToSwift() -> FetchContactsQueueBatchResult { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Conversion.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Conversion.swift index 489faea9e..4e026ab57 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Conversion.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Conversion.swift @@ -74,6 +74,11 @@ extension Data { return result */ } + + func toKotlinByteVector() -> Bitcoin_kmpByteVector { + + return Bitcoin_kmpByteVector(bytes: self.toKotlinByteArray()) + } } extension Array { @@ -87,3 +92,19 @@ extension Array { } } } + +extension KotlinInstant { + + func toDate() -> Date { + let milliseconds: Int64 = self.toEpochMilliseconds() + return Date(timeIntervalSince1970: TimeInterval(milliseconds) / TimeInterval(1_000)) + } +} + +extension Date { + + func toKotlinInstant() -> KotlinInstant { + let milliseconds = Int64(self.timeIntervalSince1970 * 1_000) + return KotlinInstant.companion.fromEpochMilliseconds(epochMilliseconds: milliseconds) + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift index 85e3fdf09..add21b18b 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift @@ -46,7 +46,7 @@ extension Lightning_kmpPaymentRequest { extension Lightning_kmpPeer { var bootChannelsFlowValue: Dictionary { - if let value = self.bootChannelsFlow.value as? Dictionary { + if let value = self.bootChannelsFlow.value { return value } else { return [:] diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift index 88e5d26b2..2a1272a2a 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift @@ -32,6 +32,20 @@ extension BalanceManager { } } +extension SqliteCardsDb { + + var cardsListValue: [BoltCardInfo] { + return self.cardsList.value + } +} + +extension CurrencyManager { + + var ratesFlowValue: [ExchangeRate] { + return self.ratesFlow.value + } +} + extension ConnectionsManager { var currentValue: Connections { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift index ab15bda27..3dec73e42 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift @@ -199,6 +199,19 @@ extension WalletPaymentMetadata { userDescription: nil, userNotes: nil, lightningAddress: nil, + cardId: nil, + modifiedAt: nil + ) + } + + static func withCard(_ cardID: Lightning_kmpUUID) -> WalletPaymentMetadata { + return WalletPaymentMetadata( + lnurl: nil, + originalFiat: nil, + userDescription: nil, + userNotes: nil, + lightningAddress: nil, + cardId: cardID, modifiedAt: nil ) } diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift index c6e19ca74..a6ab857fa 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinIdentifiable.swift @@ -46,6 +46,9 @@ extension ContactOffer: @retroactive Identifiable {} extension ContactAddress: @retroactive Identifiable {} extension ContactInfo: @retroactive Identifiable {} +extension BoltCardInfo: @retroactive Identifiable { +} + extension Lightning_kmpWalletState.Utxo: @retroactive Identifiable { public var id: String { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift index 621550bf8..530b4fb5a 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinPublishers+Phoenix.swift @@ -105,6 +105,17 @@ extension ConnectionsManager { } } +// MARK: - +extension SqliteCardsDb { + + func cardsListSequence() -> AnyAsyncSequence<[BoltCardInfo]> { + + return self.cardsList + .compactMap { $0 } + .eraseToAnyAsyncSequence() + } +} + // MARK: - extension CurrencyManager { @@ -215,6 +226,16 @@ extension PaymentsPageFetcher { // MARK: - +extension CloudKitCardsDb { + + func queueCountSequence() -> AnyAsyncSequence { + + return self.queueCount + .map { $0.int64Value } + .eraseToAnyAsyncSequence() + } +} + extension CloudKitContactsDb { func queueCountSequence() -> AnyAsyncSequence { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift index f2f1e0c58..329ec8726 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift @@ -50,6 +50,7 @@ typealias Lightning_kmpNewChannelIncomingPayment = Lightning_kmp_coreNewChannelI typealias Lightning_kmpNodeEvents = Lightning_kmp_coreNodeEvents typealias Lightning_kmpNodeParams = Lightning_kmp_coreNodeParams typealias Lightning_kmpOfferManagerCompanion = Lightning_kmp_coreOfferManagerCompanion +typealias Lightning_kmpOfferInvoiceReceived = Lightning_kmp_coreOfferInvoiceReceived typealias Lightning_kmpOfferNotPaid = Lightning_kmp_coreOfferNotPaid typealias Lightning_kmpOfferPaymentMetadata = Lightning_kmp_coreOfferPaymentMetadata typealias Lightning_kmpOfferTypesOffer = Lightning_kmp_coreOfferTypesOffer diff --git a/phoenix-ios/phoenix-ios/nfc/BoltCardScan.swift b/phoenix-ios/phoenix-ios/nfc/BoltCardScan.swift new file mode 100644 index 000000000..1e71275ec --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/BoltCardScan.swift @@ -0,0 +1,138 @@ +import Foundation +import PhoenixShared +import CoreNFC + +fileprivate let filename = "BoltCardScan" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +class BoltCardScan { + + static func parse(_ message: NFCNDEFMessage) -> Card? { + + var v1Results: [V1] = [] + var v2Results: [V2] = [] + + message.records.forEach { payload in + if let uri = payload.wellKnownTypeURIPayload() { + log.debug("found uri = \(uri)") + v1Results.append(V1(url: uri)) + + } else if let text = payload.wellKnownTypeTextPayload().0 { + log.debug("found text = \(text)") + if let v2 = V2.parse(text) { + v2Results.append(v2) + } + } + } + + if let v2 = v2Results.first { + return Card.v2(v2: v2) + } else if let hybrid = v1Results.first(where: { $0.v2 != nil }) { + return Card.v1(v1: hybrid) + } else if let v1 = v1Results.first { + return Card.v1(v1: v1) + } else { + return nil + } + } + + enum Card { + case v1(v1: V1) + case v2(v2: V2) + } + + struct V1 { + let url: URL + + var v2: V2? { + + guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + var queryItems = comps.queryItems ?? [] + + let v2Items = queryItems.filter { $0.name.lowercased() == "v2" && $0.value != nil } + guard !v2Items.isEmpty else { + return nil + } + + queryItems.removeAll(where: { $0.name.lowercased() == "v2" }) + comps.queryItems = queryItems + let cardParams = comps.percentEncodedQuery ?? "" + + let v2List: [V2] = v2Items.compactMap { V2.parse($0.value ?? "", cardParams) } + return v2List.first + } + } + + struct V2 { + let baseText: String + let parametersText: String + + let base: Format + let parameters: [URLQueryItem] + + var text: String { + return "\(baseText)?\(parametersText)" + } + + enum Format { + case offer(offer: Lightning_kmpOfferTypesOffer) + case address(address: String) + } + + fileprivate static func parse(_ text: String) -> V2? { + + let comps = text.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + if comps.count == 2 { + return parse(String(comps[0]), String(comps[1])) + } else { + return nil + } + } + + fileprivate static func parse(_ baseText: String, _ parametersText: String) -> V2? { + + var base: Format? = nil + if baseText.starts(with: "lno") { + let result: Bitcoin_kmpTry = + Lightning_kmpOfferTypesOffer.companion.decode(s: baseText) + + if result.isSuccess { + let offer: Lightning_kmpOfferTypesOffer = result.get()! + base = Format.offer(offer: offer) + } + + } else if baseText.starts(with: "₿") { + + let addr = baseText.substring(location: 1) + if addr.isValidEmailAddress() { + base = Format.address(address: addr) + } + } + + if let base { + + var parameters: [URLQueryItem] = [] + if let url = URL(string: "https://www.apple.com?\(parametersText)"), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + { + parameters = components.queryItems ?? [] + } + + return V2( + baseText: baseText, + parametersText: parametersText, + base: base, + parameters: parameters + ) + } else { + return nil + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/tools/CommandAPDU.swift b/phoenix-ios/phoenix-ios/nfc/CommandAPDU.swift similarity index 87% rename from phoenix-ios/phoenix-ios/nfc/tools/CommandAPDU.swift rename to phoenix-ios/phoenix-ios/nfc/CommandAPDU.swift index dbb589a9a..3d4ed7601 100644 --- a/phoenix-ios/phoenix-ios/nfc/tools/CommandAPDU.swift +++ b/phoenix-ios/phoenix-ios/nfc/CommandAPDU.swift @@ -170,3 +170,26 @@ struct ReadBinaryCommand { let offset: UInt16 let length: UInt8 } + +extension Array where Element == UInt8 { + + public func readBigEndian( + offset: Int, + as: T.Type + ) -> T { + + assert(offset + MemoryLayout.size <= self.count) + + // Prepare a region aligned for `T` + var value: T = 0 + // Copy the misaligned bytes at `offset` to aligned region `value` + _ = Swift.withUnsafeMutableBytes(of: &value) {valueBP in + self.withUnsafeBytes { bufPtr in + let range = offset ..< (offset + MemoryLayout.size) + bufPtr.copyBytes(to: valueBP, from: range) + } + } + + return T(bigEndian: value) + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/HceWriter.swift b/phoenix-ios/phoenix-ios/nfc/HceWriter.swift index 54ffb1eb6..ee8670ea3 100644 --- a/phoenix-ios/phoenix-ios/nfc/HceWriter.swift +++ b/phoenix-ios/phoenix-ios/nfc/HceWriter.swift @@ -1,5 +1,6 @@ import Foundation import CoreNFC +import DnaCommunicator fileprivate let filename = "HceWriter" #if DEBUG @@ -322,3 +323,33 @@ class HceWriter { ] } } + +extension CapabilitiesContainer { + + static func hce_defaultValue() -> CapabilitiesContainer { + + let file2 = CtrlTLV.hce_defaultFile2() + + return CapabilitiesContainer( + len: 15, + version: 0x20, + mLe: 256, + mLc: 255, + files: [file2] + ) + } +} + +extension CtrlTLV { + + static func hce_defaultFile2() -> CtrlTLV { + return CtrlTLV( + t: 4, + l: 6, + fileId: [0xe1, 0x04], + fileSize: 512, + readAccess: 0x00, + writeAccess: 0xFF + ) + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/NFCReaderError+Ignore.swift b/phoenix-ios/phoenix-ios/nfc/NFCReaderError+Ignore.swift new file mode 100644 index 000000000..52d74648a --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/NFCReaderError+Ignore.swift @@ -0,0 +1,40 @@ +import Foundation +import CoreNFC + +fileprivate let filename = "NFCReaderError+Ignore" +#if DEBUG +fileprivate let log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +extension NFCReaderError { + + /// Some "errors" aren't exactly errors. Like if the user taps the "cancel" button. + /// That's not an error from the user's point-of-view, + /// and thus doesn't require an error message to be displayed on the screen. + /// + func isIgnorable() -> Bool { + + switch self.code { + case .readerSessionInvalidationErrorUserCanceled: + // User tapped "cancel" button + log.debug("readerSessionInvalidationErrorUserCanceled") + return true + + case .readerSessionInvalidationErrorSessionTimeout: + // User didn't present a card to the reader. + // The NFC reader automatically cancelled after 60 seconds. + log.debug("readerSessionInvalidationErrorSessionTimeout") + return true + + case .readerSessionInvalidationErrorSessionTerminatedUnexpectedly: + // User locked the phone, which automatically terminates the NFC reader. + log.debug("readerSessionInvalidationErrorSessionTerminatedUnexpectedly") + return true + + default: + return false + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/NfcReader.swift b/phoenix-ios/phoenix-ios/nfc/NfcReader.swift index 97da4e7ff..766df0ab9 100644 --- a/phoenix-ios/phoenix-ios/nfc/NfcReader.swift +++ b/phoenix-ios/phoenix-ios/nfc/NfcReader.swift @@ -129,5 +129,9 @@ class NfcReader: NSObject, NFCNDEFReaderSessionDelegate { finishWithSuccess(message) } } - + + /* Note: When this delegate function exists, it seems to break functionality on iOS 16. + func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [any NFCNDEFTag]) { + } + */ } diff --git a/phoenix-ios/phoenix-ios/nfc/NfcWriter.swift b/phoenix-ios/phoenix-ios/nfc/NfcWriter.swift new file mode 100644 index 000000000..0fbf8b6fd --- /dev/null +++ b/phoenix-ios/phoenix-ios/nfc/NfcWriter.swift @@ -0,0 +1,1135 @@ +import Foundation +import CoreNFC +import DnaCommunicator + +fileprivate let filename = "NfcWriter" +#if DEBUG +fileprivate let log = LoggerFactory.shared.logger(filename, .trace) +fileprivate let dnaLog = LoggerFactory.shared.logger("DnaCommunicator", .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +fileprivate let dnaLog = LoggerFactory.shared.logger("DnaCommunicator", .warning) +#endif + +class NfcWriter: NSObject, NFCTagReaderSessionDelegate { + + // -------------------------------------------------- + // MARK: Struct's & Enum's + // -------------------------------------------------- + + struct WriteInput { + let template: Ndef.Template + let key0: [UInt8] + let piccDataKey: [UInt8] + let cmacKey: [UInt8] + } + + struct WriteOutput { + let chipUid: [UInt8] + } + + enum WriteError: Error { + case readingNotAvailable + case alreadyStarted + case couldNotConnect + case couldNotAuthenticate + case keySlotsUnavailable + case protocolError(WriteStep, Error) + case scanningTerminated(NFCReaderError) + } + + enum WriteStep: Int { + case readChipUid + case writeFile2Settings + case writeFile2Data + case writeKey0 + } + + struct ResetInput { + let key0: [UInt8] + let piccDataKey: [UInt8] + let cmacKey: [UInt8] + } + + enum DebugError: Error { + case readingNotAvailable + case alreadyStarted + case couldNotConnect + case couldNotAuthenticate + case readChipUid(Error) + case readFile1Settings(Error) + case readFile1Data(Error) + case readFile2Settings(Error) + case readFile2Data(Error) + case readFile3Settings(Error) + case readFile3Data(Error) + case scanningTerminated(NFCReaderError) + } + + // -------------------------------------------------- + // MARK: Variables + // -------------------------------------------------- + + static let shared = NfcWriter() + + private let queue: DispatchQueue + + private var session: NFCTagReaderSession? = nil + + private var writeInput: WriteInput? = nil + private var writeCallback: ((Result) -> Void)? = nil + + private var resetInput: ResetInput? = nil + private var resetCallback: ((Result) -> Void)? = nil + + private var debugCallback: ((Result) -> Void)? = nil + + // -------------------------------------------------- + // MARK: General + // -------------------------------------------------- + + override init() { + queue = DispatchQueue(label: "NfcWriter") + } + + private var isWriting: Bool { + return (writeCallback != nil) + } + + private var isResetting: Bool { + return (resetCallback != nil) + } + + private func connectToTag(_ tag: NFCTag) async { + log.trace("connectToTag()") + + guard case let .iso7816(isoTag) = tag else { + preconditionFailure("invalid tag parameter") + } + + guard let session else { + log.warning("connectToTag: ignoring: session is nil") + return + } + + do { + try await session.connect(to: tag) + + log.debug("session.connect(): success") + + let dnaLogger = {(msg: String) -> Void in + dnaLog.debug("\(msg)") + } + let dna = DnaCommunicator(tag: isoTag, logger: dnaLogger) + + Task { + await authenticate(dna) + } + + } catch { + log.debug("session.connect(): failed: \(error)") + if isWriting { + writeDisconnect(error: .couldNotConnect) + } else if isResetting { + resetDisconnect(error: .couldNotConnect) + } else { + debugDisconnect(error: .couldNotConnect) + } + } + } + + private func authenticate( + _ dna: DnaCommunicator + ) async { + + log.trace("authenticateToTag()") + + let key0: [UInt8] + if writeInput != nil { + // We are expecting an empty card here. + key0 = DnaCommunicator.defaultKey + } else if let input = resetInput { + // We are expecting a non-empty card, so we need to use proper key. + key0 = input.key0 + } else { + // We're debugging, and expecting an empty card. + key0 = DnaCommunicator.defaultKey + } + + let result = await dna.authenticateEV2First( + keyNum : .KEY_0, + keyData : key0 + ) + + switch result { + case .failure(let error): + log.debug("dna.authenticateEV2First(keyNum: 0): failed: \(error)") + if isWriting { + writeDisconnect(error: .couldNotAuthenticate) + } else if isResetting { + resetDisconnect(error: .couldNotAuthenticate) + } else { + debugDisconnect(error: .couldNotAuthenticate) + } + + case .success(_): + log.debug("dna.authenticateEV2First(keyNum: 0): success") + + Task { + if isWriting { + await writeDriver(dna) + } else if isResetting { + await resetDriver(dna) + } else { + await debugDriver(dna) + } + } + } + } + + // -------------------------------------------------- + // MARK: Write Logic + // -------------------------------------------------- + + func writeCard( + _ input: WriteInput, + _ callback: @escaping (Result) -> Void + ) { + log.trace("startWriting()") + + let fail = { (error: WriteError) in + DispatchQueue.main.async { + callback(.failure(error)) + } + } + + queue.async { [self] in + + guard NFCReaderSession.readingAvailable else { + log.error("NFCReaderSession.readingAvailable is false") + return fail(.readingNotAvailable) + } + + guard session == nil else { + log.error("session is already started") + return fail(.alreadyStarted) + } + + session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: queue) + session?.alertMessage = String( + localized: "Hold your card near the device to program it.", + comment: "Message in iOS NFC dialog" + ) + + self.writeInput = input + self.writeCallback = callback + session?.begin() + + log.info("session is ready") + } + } + + private func writeDriver( + _ dna: DnaCommunicator + ) async { + + log.trace("writeDriver()") + + guard let input = writeInput else { + fatalError("input is nil") + } + + // Step 1 of 5: + // Read the chip UID + + let chipUid: [UInt8] + do { + chipUid = try await readChipUid(dna).get() + } catch { + return writeDisconnect(error: .protocolError(.readChipUid, error)) + } + + // Step 2 of 5: + // Write piccDataKey & cmacKey to the card. + // + // Ideally we'll put: + // - piccDataKey=key1, cmacKey=key2 + // + // But that may not be possible. + // If the card was reset incorrectly, then certain keys may not be available to us. + // + // However, other key configurations are perfectly acceptable for our use case: + // - piccDataKey=key1, cmacKey=key3 + // - piccDataKey=key1, cmacKey=key4 + // - piccDataKey=key2, cmacKey=key3 + // - piccDataKey=key2, cmacKey=key4 + // - piccDataKey=key3, cmacKey=key4 + // + // So we'll try our best to program the card with the keys that are available to us. + + var position = await writeKey(dna, input.piccDataKey, startingPosition: .KEY_1) + guard let piccDataKeyPosition = position else { + return writeDisconnect(error: .keySlotsUnavailable) + } + log.debug("piccDataKeyPosition: \(piccDataKeyPosition.description)") + + position = await writeKey(dna, input.cmacKey, startingPosition: position?.next()) + guard let cmacKeyPosition = position else { + return writeDisconnect(error: .keySlotsUnavailable) + } + log.debug("cmacKeyPosition: \(cmacKeyPosition.description)") + + // Step 3 of 5: + // Write file2 settings. + + let file2Settings: FileSettings + do { + file2Settings = try await writeFile2Settings(dna, input.template, + piccDataKeyPosition: piccDataKeyPosition, + cmacKeyPosition: cmacKeyPosition + ).get() + } catch { + return writeDisconnect(error: .protocolError(.writeFile2Settings, error)) + } + + // Step 4 of 5: + // Write file2 data. + + do { + let data = input.template.data + try await writeFile2Data(dna, data, file2Settings).get() + } catch { + return writeDisconnect(error: .protocolError(.writeFile2Data, error)) + } + + // Step 5 of 5: + // Change key0 + // + // Note that after you perform this step, + // if you wanted to make other changes to the card, + // then you would need to reauthenticate. + + do { + let _ = try await changeKey(dna, .KEY_0, + oldKey : DnaCommunicator.defaultKey, + newKey : input.key0 + ).get() + } catch { + return writeDisconnect(error: .protocolError(.writeKey0, error)) + } + + writeDisconnect(output: WriteOutput(chipUid: chipUid)) + } + + private func writeDisconnect(error: WriteError) { + writeDisconnect(result: .failure(error)) + } + + private func writeDisconnect(output: WriteOutput) { + writeDisconnect(result: .success(output)) + } + + private func writeDisconnect(result: Result) { + + queue.async { + + guard let session = self.session, + let callback = self.writeCallback + else { + return + } + log.trace("disconnect()") + + session.invalidate() + self.session = nil + self.writeInput = nil + self.writeCallback = nil + + DispatchQueue.main.async { + callback(result) + } + } + } + + // -------------------------------------------------- + // MARK: Reset Logic + // -------------------------------------------------- + + func resetCard( + _ input: ResetInput, + _ callback: @escaping (Result) -> Void + ) { + log.trace("resetCard()") + + let fail = { (error: WriteError) in + DispatchQueue.main.async { + callback(.failure(error)) + } + } + + queue.async { [self] in + + guard NFCReaderSession.readingAvailable else { + log.error("NFCReaderSession.readingAvailable is false") + return fail(.readingNotAvailable) + } + + guard session == nil else { + log.error("session is already started") + return fail(.alreadyStarted) + } + + session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: queue) + session?.alertMessage = String( + localized: "Hold your card near the device to reset it.", + comment: "Message in iOS NFC dialog" + ) + + self.resetInput = input + self.resetCallback = callback + session?.begin() + + log.info("session is ready") + } + } + + private func resetDriver( + _ dna: DnaCommunicator + ) async { + + log.trace("resetDriver()") + + guard let input = resetInput else { + fatalError("input is nil") + } + + // Step 1 of 4: + // Reset piccDataKey & cmacKey. + // + // As documented in the `writeDriver` above, there are a number of combinations that are possible: + // - piccDataKey=key1, cmacKey=key2 + // - piccDataKey=key1, cmacKey=key3 + // - piccDataKey=key1, cmacKey=key4 + // - piccDataKey=key2, cmacKey=key3 + // - piccDataKey=key2, cmacKey=key4 + // - piccDataKey=key3, cmacKey=key4 + // + // For our purposes here, we will consider the card properly reset + // if we are able to reset 2 key positions. + + var position = await resetKey(dna, input.piccDataKey, startingPosition: .KEY_1) + guard let piccDataKeyPosition = position else { + return resetDisconnect(error: .keySlotsUnavailable) + } + log.debug("piccDataKeyPosition: \(piccDataKeyPosition.description)") + + position = await resetKey(dna, input.cmacKey, startingPosition: position?.next()) + guard let cmacKeyPosition = position else { + return resetDisconnect(error: .keySlotsUnavailable) + } + log.debug("cmacKeyPosition: \(cmacKeyPosition.description)") + + // Step 2 of 4: + // Reset file2 settings. + + let file2Settings = FileSettings.defaultFile2() + do { + let _ = try await writeFile2Settings(dna, file2Settings).get() + } catch { + return resetDisconnect(error: .protocolError(.writeFile2Settings, error)) + } + + // Step 3 of 4: + // Reset file2 data. + + do { + let url = URL(string: "https://phoenix.acinq.co")! + let dataInfo = Ndef.ndefDataForUrl(url) + try await writeFile2Data(dna, dataInfo.data, file2Settings).get() + } catch { + return resetDisconnect(error: .protocolError(.writeFile2Data, error)) + } + + // Step 4 of 4: + // Change key0 + // + // Note that after you perform this step, + // if you wanted to make other changes to the card, + // then you would need to reauthenticate. + + do { + let _ = try await changeKey(dna, .KEY_0, + oldKey : input.key0, + newKey : DnaCommunicator.defaultKey + ).get() + } catch { + return resetDisconnect(error: .protocolError(.writeKey0, error)) + } + + resetDisconnect(result: .success(())) + } + + private func resetDisconnect(error: WriteError) { + resetDisconnect(result: .failure(error)) + } + + private func resetDisconnect(result: Result) { + + queue.async { + + guard let session = self.session, + let callback = self.resetCallback + else { + return + } + log.trace("disconnect()") + + session.invalidate() + self.session = nil + self.debugCallback = nil + + DispatchQueue.main.async { + callback(result) + } + } + } + + // -------------------------------------------------- + // MARK: Debug Logic + // -------------------------------------------------- + +#if DEBUG + func debugSession( + _ callback: @escaping (Result) -> Void + ) { + log.trace("debugSession()") + + let fail = { (error: DebugError) in + DispatchQueue.main.async { + callback(.failure(error)) + } + } + + queue.async { [self] in + + guard NFCReaderSession.readingAvailable else { + log.error("NFCReaderSession.readingAvailable is false") + return fail(.readingNotAvailable) + } + + guard session == nil else { + log.error("session is already started") + return fail(.alreadyStarted) + } + + session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self, queue: queue) + session?.alertMessage = "Hold your card near the device to start debugging." + + self.debugCallback = callback + session?.begin() + + log.info("session is ready") + } + } +#endif + + private func debugDriver( + _ dna: DnaCommunicator + ) async { + + log.trace("debugDriver()") + + do { + let _ = try await readChipUid(dna).get() + } catch { + return debugDisconnect(error: .readChipUid(error)) + } + + let file1Settings: FileSettings + do { + file1Settings = try await readFile1Settings(dna).get() + } catch { + return debugDisconnect(error: .readFile1Settings(error)) + } + + do { + let _ = try await readFile1Data(dna, file1Settings).get() + } catch { + return debugDisconnect(error: .readFile1Data(error)) + } + + let file2Settings: FileSettings + do { + file2Settings = try await readFile2Settings(dna).get() + } catch { + return debugDisconnect(error: .readFile2Settings(error)) + } + + do { + let _ = try await readFile2Data(dna, file2Settings).get() + } catch { + return debugDisconnect(error: .readFile2Data(error)) + } + + do { + let _ = try await readFile3Settings(dna).get() + } catch { + return debugDisconnect(error: .readFile3Settings(error)) + } + + debugDisconnect(result: .success(())) + } + + private func debugDisconnect(error: DebugError) { + debugDisconnect(result: .failure(error)) + } + + private func debugDisconnect(result: Result) { + + queue.async { + + guard let session = self.session, + let callback = self.debugCallback + else { + return + } + log.trace("disconnect()") + + session.invalidate() + self.session = nil + self.debugCallback = nil + + DispatchQueue.main.async { + callback(result) + } + } + } + + // -------------------------------------------------- + // MARK: Reading + // -------------------------------------------------- + + private func readChipUid( + _ dna: DnaCommunicator + ) async -> Result<[UInt8], Error> { + + log.trace("readChipUid()") + + let result = await dna.getChipUid() + + switch result { + case .failure(let error): + log.debug("dna.getChipUid: failed: \(error)") + return .failure(error) + + case .success(let uid): + log.debug("dna.getChipUid: success") + log.debug("UID: \(uid.toHex())") + + return .success(uid) + } + } + + private func readFile1Settings( + _ dna: DnaCommunicator + ) async -> Result { + + log.trace("readFile1Settings()") + + let result = await dna.getFileSettings(fileNum: .CC_FILE) + + switch result { + case .failure(let error): + log.error("dna.getFileSettings(1): error: \(error)") + return .failure(error) + + case .success(let settings): + log.debug("dna.getFileSettings(1): success") + self.printFileSettings(settings, fileNum: 1) + + // let result = settings.encode(mode: .GetFileSettings) + // switch result { + // case .success(let encoded): + // log.debug("Encoded: \(encoded.toHex())") + // + // case .failure(let reason): + // fatalError("FileSettings.encode(): \(reason)") + // } + + return .success(settings) + } + } + + private func readFile1Data( + _ dna: DnaCommunicator, + _ settings: FileSettings + ) async -> Result { + + log.trace("readFile1Data()") + + let length = 32 + let result = await dna.readFileData( + fileNum: .CC_FILE, + length: length, + mode: settings.communicationMode + ) + + switch result { + case .failure(let error): + log.error("dna.readFileData(1): error: \(error)") + return .failure(error) + + case .success(let data): + log.debug("dna.readFileData(1): success") + + var fileData: [UInt8] = data + if fileData.count > length { + fileData = Array(fileData[0.. Result { + + log.trace("readFile2Settings()") + + let result = await dna.getFileSettings(fileNum: .NDEF_FILE) + + switch result { + case .failure(let error): + log.error("dna.getFileSettings(2): error: \(error)") + return .failure(error) + + case .success(let settings): + log.debug("dna.getFileSettings(2): success") + self.printFileSettings(settings, fileNum: 2) + + // let result = settings.encode(mode: .GetFileSettings) + // switch result { + // case .success(let encoded): + // log.debug("Encoded: \(encoded.toHex())") + // + // case .failure(let reason): + // fatalError("FileSettings.encode(): \(reason)") + // } + + return .success(settings) + } + } + + private func readFile2Data( + _ dna : DnaCommunicator, + _ settings : FileSettings, + _ prvData : [UInt8]? = nil + ) async -> Result<[UInt8], Error> { + + log.trace("readFile2Data()") + + let length = 128 // this appears to be the max + let offset = prvData?.count ?? 0 + + let result = await dna.readFileData( + fileNum : .NDEF_FILE, + offset : offset, + length : length, + mode : settings.communicationMode + ) + + switch result { + case .failure(let error): + log.error("dna.readFileData(2): error: \(error)") + return .failure(error) + + case .success(let data): + log.debug("dna.readFileData(2): success") + log.debug("data.count = \(data.count)") + + var fixedData: [UInt8] = data + if fixedData.count > length { + fixedData = Array(data[0.. Result { + + log.trace("readFile3Settings()") + + let result = await dna.getFileSettings(fileNum: .PROPRIETARY) + + switch result { + case .failure(let error): + log.error("dna.getFileSettings(3): error: \(error)") + return .failure(error) + + case .success(let settings): + log.debug("dna.getFileSettings(3): success") + self.printFileSettings(settings, fileNum: 3) + + // let result = settings.encode(mode: .GetFileSettings) + // switch result { + // case .success(let encoded): + // log.debug("Encoded: \(encoded.toHex())") + // + // case .failure(let reason): + // fatalError("FileSettings.encode(): \(reason)") + // } + + return .success(settings) + } + } + + private func readFile3Data( + _ dna: DnaCommunicator, + _ settings: FileSettings + ) async -> Result<[UInt8], Error> { + + log.trace("readFile3Data()") + + let length = 128 + let result = await dna.readFileData( + fileNum: .PROPRIETARY, + length: length, + mode: settings.communicationMode + ) + + switch result { + case .failure(let error): + log.error("dna.readFileData(3): error: \(error)") + return .failure(error) + + case .success(let data): + log.debug("dna.readFileData(3): success") + + var fileData: [UInt8] = data + if fileData.count > length { + fileData = Array(fileData[0.. KeySpecifier? { + + guard var position = startingPosition else { + return nil + } + + while true { + do { + try await changeKey(dna, position, + oldKey : DnaCommunicator.defaultKey, + newKey : newKey + ).get() + return position + + } catch { + log.info("Unable to write to: \(position.description)") + if let nextPosition = position.next() { + position = nextPosition + } else { + return nil + } + } + } + } + + private func resetKey( + _ dna : DnaCommunicator, + _ oldKey : [UInt8], + startingPosition : KeySpecifier? + ) async -> KeySpecifier? { + + guard var position = startingPosition else { + return nil + } + + while true { + do { + try await changeKey(dna, position, + oldKey : oldKey, + newKey : DnaCommunicator.defaultKey + ).get() + return position + + } catch { + log.info("Unable to write to: \(position.description)") + if let nextPosition = position.next() { + position = nextPosition + } else { + return nil + } + } + } + } + + private func changeKey( + _ dna : DnaCommunicator, + _ keyNum : KeySpecifier, + oldKey : [UInt8], + newKey : [UInt8] + ) async -> Result { + + log.trace("changeKey(\(keyNum))") + + let currentKeyVersion: UInt8 + let resultA = await dna.getKeyVersion(keyNum: keyNum) + + switch resultA { + case .failure(let error): + log.error("dna.getKeyVersion(\(keyNum)): error: \(error)") + return .failure(error) + + case .success(let version): + log.debug("dna.getKeyVersion(\(keyNum)): success: \(version)") + currentKeyVersion = version + } + + let newKeyVersion = currentKeyVersion + 1 + + let result = await dna.changeKey( + keyNum : keyNum, + oldKey : oldKey, + newKey : newKey, + keyVersion : newKeyVersion + ) + + switch result { + case .failure(let error): + log.error("dna.changeKey(\(keyNum)): error: \(error)") + return .failure(error) + + case .success(_): + log.debug("dna.changeKey(\(keyNum)): success") + return .success(()) + } + } + + private func writeFile2Settings( + _ dna : DnaCommunicator, + _ template : Ndef.Template, + piccDataKeyPosition : KeySpecifier, + cmacKeyPosition : KeySpecifier + ) async -> Result { + + log.debug("writeFile2Settings()") + + var settings = FileSettings.defaultFile2() + settings.sdmEnabled = true + settings.communicationMode = .FULL + settings.readPermission = .ALL + settings.writePermission = .KEY_0 + settings.readWritePermission = .KEY_0 + settings.changePermission = .KEY_0 + settings.sdmOptionUid = true + settings.sdmOptionReadCounter = true + settings.sdmOptionUseAscii = true + settings.sdmMetaReadPermission = piccDataKeyPosition.toPermission() + settings.sdmFileReadPermission = cmacKeyPosition.toPermission() + settings.sdmPiccDataOffset = UInt32(template.piccDataOffset) + settings.sdmMacOffset = UInt32(template.cmacOffset) + settings.sdmMacInputOffset = UInt32(template.cmacOffset) + + return await writeFile2Settings(dna, settings) + } + + private func writeFile2Settings( + _ dna : DnaCommunicator, + _ settings : FileSettings + ) async -> Result { + + log.debug("writeFile2Settings()") + + printFileSettings(settings, fileNum: 2) + + var data: [UInt8] = [] + switch settings.encode(mode: .ChangeFileSettings) { + case .failure(let error): + log.error("FileSettings.encode(): error: \(error)") + return .failure(error) + + case .success(let bytes): + log.debug("FileSettings.encode(): success: \(bytes.toHex())") + data = bytes + } + + let result = await dna.changeFileSettings(fileNum: .NDEF_FILE, data: data) + + switch result { + case .failure(let error): + log.error("dna.changeFileSettings(2): error: \(error)") + return .failure(error) + + case .success(_): + log.debug("dna.changeFileSettings(2): success") + return .success(settings) + } + } + + private func writeFile2Data( + _ dna : DnaCommunicator, + _ data : [UInt8], + _ settings : FileSettings + ) async -> Result { + + log.debug("writeFile2Data()") + log.debug("data.count = \(data.count)") + + let result = await dna.writeFileData( + fileNum : .NDEF_FILE, + data : data, + mode : settings.communicationMode + ) + + switch result { + case .failure(let error): + log.error("dna.writeFileData(2): error: \(error)") + return .failure(error) + + case .success(_): + log.debug("dna.writeFileData(2): success") + return .success(()) + } + } + + // -------------------------------------------------- + // MARK: Utilities + // -------------------------------------------------- + + func printFileSettings(_ fileSettings: FileSettings, fileNum: Int) { + + var output: String = "" + output += "FileSettings(\(fileNum)):\n" + output += " - fileType: \(fileSettings.fileType)\n" + output += " - sdmEnabled: \(fileSettings.sdmEnabled)\n" + output += " - communicationMode: \(fileSettings.communicationMode)\n" + output += " - readPermission: \(fileSettings.readPermission)\n" + output += " - writePermission: \(fileSettings.writePermission)\n" + output += " - readWritePermission: \(fileSettings.readWritePermission)\n" + output += " - changePermission: \(fileSettings.changePermission)\n" + output += " - fileSize: \(fileSettings.fileSize)\n" + output += " - sdmOptionUid: \(fileSettings.sdmOptionUid)\n" + output += " - sdmOptionReadCounter: \(fileSettings.sdmOptionReadCounter)\n" + output += " - sdmOptionReadCounterLimit: \(fileSettings.sdmOptionReadCounterLimit)\n" + output += " - sdmOptionEncryptFileData: \(fileSettings.sdmOptionEncryptFileData)\n" + output += " - sdmOptionUseAscii: \(fileSettings.sdmOptionUseAscii)\n" + output += " - sdmMetaReadPermission: \(fileSettings.sdmMetaReadPermission)\n" + output += " - sdmFileReadPermission: \(fileSettings.sdmFileReadPermission)\n" + output += " - sdmReadCounterRetrievalPermission: \(fileSettings.sdmReadCounterRetrievalPermission)\n" + output += " - sdmUidOffset: \(fileSettings.sdmUidOffset?.description ?? "nil")\n" + output += " - sdmReadCounterOffset: \(fileSettings.sdmReadCounterOffset?.description ?? "nil")\n" + output += " - sdmPiccDataOffset: \(fileSettings.sdmPiccDataOffset?.description ?? "nil")\n" + output += " - sdmMacInputOffset: \(fileSettings.sdmMacInputOffset?.description ?? "nil")\n" + output += " - sdmMacOffset: \(fileSettings.sdmMacOffset?.description ?? "nil")\n" + output += " - sdmEncOffset: \(fileSettings.sdmEncOffset?.description ?? "nil")\n" + output += " - sdmEncLength: \(fileSettings.sdmEncLength?.description ?? "nil")\n" + output += " - sdmReadCounterLimit: \(fileSettings.sdmReadCounterLimit?.description ?? "nil")" + + log.debug("\(output)") + } + + func printCapabilitiesContainer(_ cc: CapabilitiesContainer) { + + log.debug("\(cc.description)") + } + + // -------------------------------------------------- + // MARK: NFCTagReaderSessionDelegate + // -------------------------------------------------- + + func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) { + log.trace("tagReaderSessionDidBecomeActive(_):") + } + + func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: any Error) { + log.trace("tagReaderSession(_, didInvalidateWithError:)") + log.trace("error: \(error)") + + let nfcError = (error as? NFCReaderError) ?? // this is always the case + NFCReaderError(NFCReaderError.readerSessionInvalidationErrorSessionTimeout) // but just to be safe + + if isWriting { + writeDisconnect(error: .scanningTerminated(nfcError)) + } else if isResetting { + resetDisconnect(error: .scanningTerminated(nfcError)) + } else { + debugDisconnect(error: .scanningTerminated(nfcError)) + } + } + + func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { + log.trace("tagReaderSession(_, didDetect:): \(tags)") + log.trace("tags.count = \(tags.count)") + + for tag in tags { + log.debug("tag: \(tag)") + } + + var properTag: NFCTag? = nil + for tag in tags { + if case .iso7816 = tag { + if properTag == nil { + properTag = tag + } + } + } + + if let properTag { + Task { + await connectToTag(properTag) + } + } else { + session.restartPolling() + log.debug("did NOT find properTag") + } + } +} diff --git a/phoenix-ios/phoenix-ios/nfc/extensions/Array+Read.swift b/phoenix-ios/phoenix-ios/nfc/extensions/Array+Read.swift deleted file mode 100644 index 76eea276c..000000000 --- a/phoenix-ios/phoenix-ios/nfc/extensions/Array+Read.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -extension Array where Element == UInt8 { - - func readLittleEndian( - offset: Int, - as: T.Type - ) -> T { - - assert(offset + MemoryLayout.size <= self.count) - - // Prepare a region aligned for `T` - var value: T = 0 - // Copy the misaligned bytes at `offset` to aligned region `value` - _ = Swift.withUnsafeMutableBytes(of: &value) {valueBP in - self.withUnsafeBytes { bufPtr in - let range = offset...size - bufPtr.copyBytes(to: valueBP, from: range) - } - } - - return T(littleEndian: value) - } - - func readBigEndian( - offset: Int, - as: T.Type - ) -> T { - - assert(offset + MemoryLayout.size <= self.count) - - // Prepare a region aligned for `T` - var value: T = 0 - // Copy the misaligned bytes at `offset` to aligned region `value` - _ = Swift.withUnsafeMutableBytes(of: &value) {valueBP in - self.withUnsafeBytes { bufPtr in - let range = offset...size - bufPtr.copyBytes(to: valueBP, from: range) - } - } - - return T(bigEndian: value) - } -} diff --git a/phoenix-ios/phoenix-ios/nfc/extensions/ByteArrayConversions.swift b/phoenix-ios/phoenix-ios/nfc/extensions/ByteArrayConversions.swift deleted file mode 100644 index 71b1706bd..000000000 --- a/phoenix-ios/phoenix-ios/nfc/extensions/ByteArrayConversions.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -extension Array where Element == UInt8 { - - func toData() -> Data { - return Data(bytes: self, count: self.count) - } -} - -extension Data { - - func toByteArray() -> [UInt8] { - var buffer = [UInt8]() - self.withUnsafeBytes { - buffer.append(contentsOf: $0) - } - return buffer - } -} - -extension FixedWidthInteger { - - func toByteArray() -> [UInt8] { - withUnsafeBytes(of: self, Array.init) - } -} diff --git a/phoenix-ios/phoenix-ios/nfc/tools/CapabilitiesContainer.swift b/phoenix-ios/phoenix-ios/nfc/tools/CapabilitiesContainer.swift deleted file mode 100644 index a9a54ac9d..000000000 --- a/phoenix-ios/phoenix-ios/nfc/tools/CapabilitiesContainer.swift +++ /dev/null @@ -1,227 +0,0 @@ -import Foundation - -struct CapabilitiesContainer { - - static let minByteCount: Int = 7 - - /// Capabilities container length - /// Indicates the size of this capability container (including this field). - /// Valid values are: 000Fh-FFFEh (15-65534) - /// - var len: UInt16 - - /// Mapping version - /// Indicates the mapping specification version the tag is compliant with. - /// The most significant nibble (4 most significant bits) indicate the major version number. - /// The least significant nibble (4 least significant bits) indicate the minor version number. - /// - var version: UInt8 - - /// Maximum R-APDU data size - /// Defines the maximum data size that can be read from - /// the tag using a single ReadBinary command. - /// Valid values are: 000Fh-FFFFh (15-65535) - /// - var mLe: UInt16 - - /// Maximum C-APDU data size - /// Defines the maximum data size that can be sent to - /// the tag using a single UpdateBinary command. - /// Valid values are: 0001h-FFFFh (1-65535) - /// - var mLc: UInt16 - - /// NDEF files - /// TLV blocks that contain information to control and manage each available NDEF file. - /// - var files: [CtrlTLV] - - init(len: UInt16, version: UInt8, mLe: UInt16, mLc: UInt16, files: [CtrlTLV]) { - self.len = len - self.version = version - self.mLe = mLe - self.mLc = mLc - self.files = files - } - - init?(data: [UInt8]) { - - guard data.count >= CapabilitiesContainer.minByteCount else { return nil } - - len = data.readBigEndian(offset: 0, as: UInt16.self) - version = data[2] - mLe = data.readBigEndian(offset: 3, as: UInt16.self) - mLc = data.readBigEndian(offset: 5, as: UInt16.self) - - var fileList: [CtrlTLV] = [] - var start = 7 - var end = start + CtrlTLV.byteCount - while (data.count >= end) { - guard let file = CtrlTLV(data: Array(data[start.. [UInt8] { - - var buffer: [UInt8] = Array() - buffer.reserveCapacity(CapabilitiesContainer.minByteCount + (CtrlTLV.byteCount * files.count)) - - buffer.append(contentsOf: len.bigEndian.toByteArray()) - buffer.append(version) - buffer.append(contentsOf: mLe.bigEndian.toByteArray()) - buffer.append(contentsOf: mLc.bigEndian.toByteArray()) - - files.forEach { file in - buffer.append(contentsOf: file.encode()) - } - - return buffer - } - - static func ntag424Dna_defaultValue() -> CapabilitiesContainer { - - let file2 = CtrlTLV.ntag424Dna_defaultFile2() - let file3 = CtrlTLV.ntag424Dna_defaultFile3() - - return CapabilitiesContainer( - len: 23, - version: 0x20, - mLe: 256, - mLc: 255, - files: [file2, file3] - ) - } - - static func hce_defaultValue() -> CapabilitiesContainer { - - let file2 = CtrlTLV.hce_defaultFile2() - - return CapabilitiesContainer( - len: 15, - version: 0x20, - mLe: 256, - mLc: 255, - files: [file2] - ) - } -} - -struct CtrlTLV { - - static let byteCount = 8 - - /// Type of TLV block - /// Valid values are: - /// - 4: NDEF File Control TLV - /// - 5: Proprietary File Control TLV - /// - var t: UInt8 - - /// Size in bytes of the value field. - /// Must be 6 for this implementation. - /// - let l: UInt8 - - /// File Identifier - /// The valid ranges are: - /// - 0001h - E101h - /// - E104h - 3EFFh - /// - 3F01h - 3FFEh - /// - 4000h - FFFEh - /// Other ranges are reserved for future use. - /// - let fileId: [UInt8] - - /// Maximum file size (i.e. max storage capacity). - /// - let fileSize: UInt16 - - /// NDEF file read access condition: - /// - 00h: indicates read access granted without any security - /// - 80h-FEh: proprietary (card-specific protocol) - /// - var readAccess: UInt8 - - /// NDEF file write access condition: - /// - 00h: indicates write access granted without any security - /// - FFh: indicates no write access granted at all (read-only) - /// - 80h-FEh: proprietary (card-specific protocol) - /// - var writeAccess: UInt8 - - init(t: UInt8, l: UInt8, fileId: [UInt8], fileSize: UInt16, readAccess: UInt8, writeAccess: UInt8) { - self.t = t - self.l = l - self.fileId = fileId - self.fileSize = fileSize - self.readAccess = readAccess - self.writeAccess = writeAccess - } - - init?(data: [UInt8]) { - - guard data.count >= CtrlTLV.byteCount else { return nil } - - t = data[0] - l = data[1] - fileId = Array(data[2...3]) - fileSize = data.readBigEndian(offset: 4, as: UInt16.self) - readAccess = data[6] - writeAccess = data[7] - } - - func encode() -> [UInt8] { - - var buffer: [UInt8] = Array() - buffer.reserveCapacity(CtrlTLV.byteCount) - - buffer.append(t) - buffer.append(l) - buffer.append(contentsOf: fileId) - buffer.append(contentsOf: fileSize.bigEndian.toByteArray()) - buffer.append(readAccess) - buffer.append(writeAccess) - - return buffer - } - - static func ntag424Dna_defaultFile2() -> CtrlTLV { - return CtrlTLV( - t: 4, - l: 6, - fileId: [0xe1, 0x04], - fileSize: 256, - readAccess: 0x00, - writeAccess: 0x00 - ) - } - - static func ntag424Dna_defaultFile3() -> CtrlTLV { - return CtrlTLV( - t: 5, - l: 6, - fileId: [0xe1, 0x05], - fileSize: 128, - readAccess: 0x82, - writeAccess: 0x83 - ) - } - - static func hce_defaultFile2() -> CtrlTLV { - return CtrlTLV( - t: 4, - l: 6, - fileId: [0xe1, 0x04], - fileSize: 512, - readAccess: 0x00, - writeAccess: 0xFF - ) - } -} diff --git a/phoenix-ios/phoenix-ios/nfc/tools/Ndef.swift b/phoenix-ios/phoenix-ios/nfc/tools/Ndef.swift deleted file mode 100644 index 580b7510d..000000000 --- a/phoenix-ios/phoenix-ios/nfc/tools/Ndef.swift +++ /dev/null @@ -1,212 +0,0 @@ -import Foundation - -struct NdefHeaderFlags: OptionSet { - let rawValue: UInt8 - - /// Message begin flag - static let MB = NdefHeaderFlags(rawValue: 0b10000000) - /// Message end flag - static let ME = NdefHeaderFlags(rawValue: 0b01000000) - /// Chunked flag - static let CF = NdefHeaderFlags(rawValue: 0b00100000) - /// Short record flag - static let SR = NdefHeaderFlags(rawValue: 0b00010000) - /// IL (ID Length) is present - static let IL = NdefHeaderFlags(rawValue: 0b00001000) - - /// Type Name Format options: - static let TNF_WELL_KNOWN = NdefHeaderFlags(rawValue: 0b00000001) // 0x01 - static let TNF_MIME = NdefHeaderFlags(rawValue: 0b00000010) // 0x02 - /// Note: don't use this for URLS, use WELLKNOWN instead. - static let TNF_ABSOLUTE_URI = NdefHeaderFlags(rawValue: 0b00000011) // 0x03 - static let TNF_EXTERNAL = NdefHeaderFlags(rawValue: 0b00000100) // 0x04 - static let TNF_UNKNOWN = NdefHeaderFlags(rawValue: 0b00000101) // 0x05 - static let TNF_UNCHANGED = NdefHeaderFlags(rawValue: 0b00000110) // 0x06 - static let TNF_RESERVED = NdefHeaderFlags(rawValue: 0b00000111) // 0x07 -} - -enum NdefHeaderType: UInt8 { - case TEXT = 0x54 // 'T'.ascii - case URL = 0x55 // 'U'.ascii -} - -public class Ndef { - - static let URL_HEADER_SIZE = 7 - static let TEXT_HEADER_SIZE = 9 - - class func ndefDataForUrl(_ url: URL) -> [UInt8] { - - // See pgs. 30-31 of AN12196 - - // NDEF File Format: - // - // Field | Length | Description - // --------------------------------------------------------------- - // NLEN | 2 bytes | Length of the NDEF message in big-endian format. - // NDEF Message | NLEN bytes | NDEF message. See NFC Data Exchange Format (NDEF). - // - // https://docs.nordicsemi.com/bundle/ncs-latest/page/nrfxlib/nfc/doc/type_4_tag.html#t4t-format - // - var fileHeader: [UInt8] = [ - 0x00, // Placeholder for NLEN - 0x00, // Placeholder for NLEN - ] - - // Header for: Well-known-type(URL) - // - // Note: If you have a long URL that doesn't fit, you can change the typeHeader here. - // For example, if you specify 0x02 for the typeHeader, it means: - // - prepend `https://www.` to the URL content, saving a few bytes. - // - let typeHeader: [UInt8] = [ - 0x00 // Just the URI (no prepended protocol) - ] - - let urlData = url.absoluteString.data(using: .utf8) ?? Data() - let urlBytes = urlData.toByteArray() - - // NDEF Message header: - - let messageHeader: [UInt8] - - let fitsInShortRecord = (typeHeader.count + urlBytes.count) <= 255 - if fitsInShortRecord { - - let payloadLength = UInt8(typeHeader.count + urlBytes.count) - - let flags: NdefHeaderFlags = [.MB, .ME, .SR, .TNF_WELL_KNOWN] - let type = NdefHeaderType.URL - - messageHeader = [ - flags.rawValue, // NDEF header flags - 0x01, // Type length - payloadLength, // Payload length (SR = 1 byte) - type.rawValue // Well-known type: URL - ] - - } else { - - let payloadLengthLE = UInt32(typeHeader.count + urlBytes.count) - let payloadLength: [UInt8] = payloadLengthLE.bigEndian.toByteArray() - - let flags: NdefHeaderFlags = [.MB, .ME, .TNF_WELL_KNOWN] - let type = NdefHeaderType.URL - - messageHeader = [ - flags.rawValue, // NDEF header flags - 0x01, // Type length - payloadLength[0], // Payload length (!SR = 4 bytes) - payloadLength[1], // Payload length - payloadLength[2], // Payload length - payloadLength[3], // Payload length - type.rawValue // Well-known type: URL - ] - } - - let fileLengthLE = UInt16(messageHeader.count + typeHeader.count + urlBytes.count) - let fileLength: [UInt8] = fileLengthLE.bigEndian.toByteArray() - - fileHeader[0] = fileLength[0] - fileHeader[1] = fileLength[1] - - let result: [UInt8] = fileHeader + messageHeader + typeHeader + urlBytes - return result - } - - class func ndefDataForText(_ text: String) -> [UInt8] { - - // See pgs. 30-31 of AN12196 - - // NDEF File Format: - // - // Field | Length | Description - // --------------------------------------------------------------- - // NLEN | 2 bytes | Length of the NDEF message in big-endian format. - // NDEF Message | NLEN bytes | NDEF message. See NFC Data Exchange Format (NDEF). - // - // https://docs.nordicsemi.com/bundle/ncs-latest/page/nrfxlib/nfc/doc/type_4_tag.html#t4t-format - // - var fileHeader: [UInt8] = [ - 0x00, // Placeholder for NLEN - 0x00 // Placeholder for NLEN - ] - - // Header for: Well-known-type(TEXT) - // - // RTD TEXT specification: - // - // Byte 0 bit pattern: - // - // | 7 | 6 | 5, 4, 3, 2, 1, 0 | - // ---------------------------------------------- - // | UTF 8/16 | Reserved | Language code length | - // - // UTF-8 => 0 - // UTF-16 => 1 - // - // Reserved => must be 0 - // - // Language code should use ISO/IANA language code. - // We will use "en" - although for our use case it will be ignored. - // - // Thus our bit pattern is: - // 0b00000010 = 0x02 - // - let typeHeader: [UInt8] = [ - 0x02, // UTF-8; langCode.length = 2 - 0x65, // 'e' - 0x6e // 'n' - ] - - let textData = text.data(using: .utf8) ?? Data() - let textBytes = textData.toByteArray() - - // NDEF Message header: - - let messageHeader: [UInt8] - - let fitsInShortRecord = (typeHeader.count + textBytes.count) <= 255 - if fitsInShortRecord { - - let payloadLength = UInt8(typeHeader.count + textBytes.count) - - let flags: NdefHeaderFlags = [.MB, .ME, .SR, .TNF_WELL_KNOWN] - let type = NdefHeaderType.TEXT - - messageHeader = [ - flags.rawValue, // NDEF header flags - 0x01, // Type length - payloadLength, // Payload length (SR = 1 byte) - type.rawValue // Well-known type: TEXT - ] - - } else { - - let payloadLengthLE = UInt32(typeHeader.count + textBytes.count) - let payloadLength: [UInt8] = payloadLengthLE.bigEndian.toByteArray() - - let flags: NdefHeaderFlags = [.MB, .ME, .TNF_WELL_KNOWN] - let type = NdefHeaderType.URL - - messageHeader = [ - flags.rawValue, // NDEF header flags - 0x01, // Type length - payloadLength[0], // Payload length (!SR = 4 bytes) - payloadLength[1], // Payload length - payloadLength[2], // Payload length - payloadLength[3], // Payload length - type.rawValue // Well-known type: URL - ] - } - - let fileLengthLE = UInt16(messageHeader.count + typeHeader.count + textBytes.count) - let fileLength: [UInt8] = fileLengthLE.bigEndian.toByteArray() - - fileHeader[0] = fileLength[0] - fileHeader[1] = fileLength[1] - - let result: [UInt8] = fileHeader + messageHeader + typeHeader + textBytes - return result - } -} diff --git a/phoenix-ios/phoenix-ios/notifications/LnurlWithdrawNotification.swift b/phoenix-ios/phoenix-ios/notifications/LnurlWithdrawNotification.swift new file mode 100644 index 000000000..d09cf7eaa --- /dev/null +++ b/phoenix-ios/phoenix-ios/notifications/LnurlWithdrawNotification.swift @@ -0,0 +1,125 @@ +import Foundation +import PhoenixShared +import CryptoKit + +fileprivate let filename = "LnurlWithdrawNotification" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct LnurlWithdrawNotification { + let nodeId: String + let piccData: Data + let cmac: Data + let invoice: Lightning_kmpBolt11Invoice + let invoiceAmount: Lightning_kmpMilliSatoshi + let invoiceString: String + let timestamp: Date + let withdrawHash: String + + init( + nodeId: String, + piccData: Data, + cmac: Data, + invoice: Lightning_kmpBolt11Invoice, + invoiceAmount: Lightning_kmpMilliSatoshi, + invoiceString: String, + timestamp: Date + ) { + self.nodeId = nodeId + self.piccData = piccData + self.cmac = cmac + self.invoice = invoice + self.invoiceAmount = invoiceAmount + self.invoiceString = invoiceString + self.timestamp = timestamp + self.withdrawHash = Self.calculateWithdrawHash( + nodeId: nodeId, piccData: piccData, cmac: cmac, invoice: invoiceString + ) + } + + /// The withdrawHash is used by the server to refer to the request. + /// When we post a response, we send the hash (as opposed to sending the entire request). + /// + /// We are expected to calculate the withdrawHash in the same way the server does. + /// So do not change this method unless you change the server code also. + /// + private static func calculateWithdrawHash( + nodeId : String, + piccData : Data, + cmac : Data, + invoice : String + ) -> String { + + var hashMe = Data() + hashMe.append(nodeId.lowercased().data(using: .utf8)!) + hashMe.append(piccData.toHex(.lowerCase).data(using: .utf8)!) + hashMe.append(cmac.toHex(.lowerCase).data(using: .utf8)!) + hashMe.append(invoice.data(using: .utf8)!) + + let digest = SHA256.hash(data: hashMe) + return digest.toHex(.lowerCase) + } + + func postResponse(errorReason: String?) async -> Bool { + log.trace("postResponse(\(errorReason ?? ""))") + + let url = URL(string: "https://phoenix.deusty.com/v1/pub/lnurlw/response")! + + var body: [String: String] = [ + "node_id" : self.nodeId, + "withdraw_hash" : self.withdrawHash, + ] + if let errorReason { + body["err_message"] = errorReason + } + + let bodyData = try? JSONSerialization.data( + withJSONObject: body, + options: [] + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = bodyData + + do { + log.debug("/v1/pub/lnurlw/response: sending...") + let (data, response) = try await URLSession.shared.data(for: request) + + var statusCode = 418 + var success = false + if let httpResponse = response as? HTTPURLResponse { + statusCode = httpResponse.statusCode + if statusCode >= 200 && statusCode < 300 { + success = true + } + } + + if success { + log.debug("/v1/pub/lnurlw/response: success") + } else { + log.debug("/v1/pub/lnurlw/response: statusCode: \(statusCode)") + if let dataString = String(data: data, encoding: .utf8) { + log.debug("/v1/pub/lnurlw/response: response:\n\(dataString)") + } + } + + return success + } catch { + log.debug("/v1/pub/lnurlw/response: error: \(String(describing: error))") + return false + } + } + + func toWithdrawRequest() -> WithdrawRequest { + return WithdrawRequest( + piccData: self.piccData, + cmac: self.cmac, + method: .bolt11Invoice(invoice: self.invoice), + amount: self.invoiceAmount + ) + } +} diff --git a/phoenix-ios/phoenix-ios/notifications/PushNotification.swift b/phoenix-ios/phoenix-ios/notifications/PushNotification.swift index 631f4728c..407ba8bd1 100644 --- a/phoenix-ios/phoenix-ios/notifications/PushNotification.swift +++ b/phoenix-ios/phoenix-ios/notifications/PushNotification.swift @@ -10,6 +10,7 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) enum PushNotification { case fcm(notification: FcmPushNotification) + case lnurlWithdraw(notification: LnurlWithdrawNotification) static func parse(_ userInfo: [AnyHashable: Any]) -> PushNotification? { log.trace(#function) @@ -129,6 +130,65 @@ enum PushNotification { // Function reserved for other debugging uses. // We sometimes trigger custom push notifications from AWS during debug sessions. - return nil + return parse_aws_lnurlWithdraw(userInfo: userInfo) + } + + private static func parse_aws_lnurlWithdraw(userInfo: [AnyHashable : Any]) -> PushNotification? { + log.trace(#function) + + // It should look like this: + // + // acinq: { + // t : "withdraw", + // n : "", + // picc : "", + // cmac : "", + // invc : "", + // ts : + // } + + guard + let acinq = userInfo["acinq"] as? [String: Any], + let t = acinq["t"] as? String, + let n = acinq["n"] as? String, + let picc = acinq["picc"] as? String, + let cmac = acinq["cmac"] as? String, + let invc = acinq["invc"] as? String, + let ts = acinq["ts"] as? Int64 + else { + log.debug("\(#function): missing one or more parameters") + return nil + } + + guard t == "withdraw" else { + log.debug("\(#function): t != withdraw") + return nil + } + guard let piccData = Data(fromHex: picc) else { + log.debug("\(#function): picc is not hexadecimal") + return nil + } + guard let cmacData = Data(fromHex: cmac) else { + log.debug("\(#function): cmac is not hexadecimal") + return nil + } + guard let invoice = Parser.shared.readBolt11Invoice(input: invc) else { + log.debug("\(#function): invc is not Bolt11Invoice") + return nil + } + guard let invoiceAmount = invoice.amount else { + log.debug("\(#function): invoice.amount is nil") + return nil + } + + return PushNotification.lnurlWithdraw(notification: LnurlWithdrawNotification( + nodeId : n, + piccData : piccData, + cmac : cmacData, + invoice : invoice, + invoiceAmount : invoiceAmount, + invoiceString : invc, + timestamp : ts.toDate(from: .milliseconds) + )) } } diff --git a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift index 77b0ed351..7b7e3776f 100644 --- a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift +++ b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift @@ -78,6 +78,10 @@ class BusinessManager { /// public let mnemonicLanguagePublisher = CurrentValueSubject(MnemonicLanguage.english) + /// Reports incoming CardResponse messages. + /// + public let cardResponsePublisher = PassthroughSubject() + /// General wallet info (e.g. nodeId) /// public var walletInfo: WalletManager.WalletInfo? = nil @@ -175,6 +179,30 @@ class BusinessManager { } }.store(in: &cancellables) + // Card payment requests + Task { @MainActor [self] in + let peer = try await self.business.peerManager.getPeer() + for await event in peer.eventsFlow { + if let msg = event as? Lightning_kmp_coreCardPaymentRequestReceived { + log.debug("found event: CardPaymentRequestReceived") + + if let cardRequest = CardRequest.fromOnionMessage(msg) { + Task { @MainActor in + await self.handleCardRequest(cardRequest) + } + } else { + log.debug("CardRequest.fromOnionMessage() failed") + } + + } else if let msg = event as? Lightning_kmp_coreCardPaymentResponseReceived { + log.debug("found event: CardPaymentResponseReceived") + + let response = CardResponse.fromOnionMessage(msg) + cardResponsePublisher.send(response) + } + } + }.store(in: &cancellables) + // Tor configuration observer groupPrefs.isTorEnabledPublisher .sink { (isTorEnabled: Bool) in @@ -472,6 +500,7 @@ class BusinessManager { self.walletInfo = _walletInfo maybeRegisterFcmToken() + maybeRegisterPushToken() let walletId = WalletIdentifier(chain: business.chain, walletInfo: _walletInfo) @@ -549,14 +578,15 @@ class BusinessManager { // -------------------------------------------------- private func pushTokenChanged() { - log.trace("pushTokenChanged()") + log.trace(#function) - // Reserved for debugging use (AWS) + // For debugging use (AWS) + maybeRegisterPushToken() } private func fcmTokenChanged() { - log.trace("fcmTokenChanged()") - + log.trace(#function) + maybeRegisterFcmToken() } @@ -569,6 +599,7 @@ class BusinessManager { if !oldPeerConnectionState.isEstablished() && newPeerConnectionState.isEstablished() { maybeRegisterFcmToken() + maybeRegisterPushToken() } } @@ -615,6 +646,108 @@ class BusinessManager { // registration. Which we could then use to trigger a storage in UserDefaults. } + func maybeRegisterPushToken() -> Void { + log.trace(#function) + assertMainThread() + + guard let walletInfo else { + log.debug("maybeRegisterPushToken: walletInfo is nil") + return + } + guard let pushToken = AppDelegate.get().pushTokenPublisher.value else { + log.debug("maybeRegisterPushToken: pushToken is nil") + return + } + guard peerConnectionState is Lightning_kmpConnection.ESTABLISHED else { + log.debug("maybeRegisterPushToken: peerConnection not established") + return + } + + let walletId = WalletIdentifier(chain: business.chain, walletInfo: walletInfo) + let prefs = Prefs.wallet(walletId) + + let nodeIdHash = walletInfo.nodeId.hash160().toSwiftData().toHex() + assert(nodeIdHash == walletInfo.nodeIdHash) + + if let prvRegistration = prefs.pushTokenRegistration { + + if prvRegistration.pushToken == pushToken && + prvRegistration.nodeIdHash == nodeIdHash + { + // We've already registered our {pushToken, nodeId} tuple. + + if abs(prvRegistration.registrationDate.timeIntervalSinceNow) < 30.days() { + // The last registration was recent, so we can skip registration. + log.debug("Push token already registered") + return + + } else { + // It's been awhile since we last registered, so let's re-register. + // This is a self-healing mechanism, in case of server problems. + } + } + } + + let registration = PushTokenRegistration( + pushToken: pushToken, + nodeIdHash: nodeIdHash, + registrationDate: Date() + ) + + let url = URL(string: "https://s7r6lsmzk7.execute-api.us-west-2.amazonaws.com/v1/pub/push/register") + guard let requestUrl = url else { return } + + #if DEBUG + let platform = "iOS-development" + #else + // Note: This is actually wrong if you build-and-run using RELEASE mode. + let platform = "iOS-production" + #endif + + let body = [ + "app_id" : "co.acinq.phoenix", + "platform" : platform, + "push_token" : pushToken, + "node_id" : walletInfo.nodeId.value.toHex() + ] + let bodyData = try? JSONSerialization.data( + withJSONObject: body, + options: [] + ) + + var request = URLRequest(url: requestUrl) + request.httpMethod = "POST" + request.httpBody = bodyData + + let task = URLSession.shared.dataTask(with: request) { (data, response, error) in + + var statusCode = 418 + var success = false + if let httpResponse = response as? HTTPURLResponse { + statusCode = httpResponse.statusCode + if statusCode >= 200 && statusCode < 300 { + success = true + } + } + + if success { + log.debug("/push/register: success") + prefs.pushTokenRegistration = registration + } + else if let error = error { + log.debug("/push/register: error: \(String(describing: error))") + } else { + log.debug("/push/register: statusCode: \(statusCode)") + if let data = data, let dataString = String(data: data, encoding: .utf8) { + log.debug("/push/register: response:\n\(dataString)") + } + } + } + + log.debug("/push/register ...") + task.resume() + } + // -------------------------------------------------- // MARK: Long-Lived Tasks // -------------------------------------------------- @@ -662,6 +795,56 @@ class BusinessManager { } } + // -------------------------------------------------- + // MARK: Card Payments + // -------------------------------------------------- + + @MainActor + func handleCardRequest( + _ cardRequest: CardRequest + ) async { + log.trace(#function) + + let result: Result = + await business.checkWithdrawRequest(cardRequest.toWithdrawRequest()) + + switch result { + case .failure(let error): + log.error("handleCardRequest: error: \(error.description)") + + // Send error message to merchant + do { + let peer = try await business.peerManager.getPeer() + try await peer.sendCardResponse( + request : cardRequest.invoice, + msg : error.cardResponseMessage, + code : error.cardResponseCode.rawValue + ) + } catch { + log.error("peer.sendCardResponse(): error: \(error)") + } + + case .success(let status): + switch status { + case .abortHandledElsewhere(_): + log.warning("handleCardReqeust: abort: handled elsewhere") + + case .continueAndSendPayment(let card, _, _): + log.debug("handleCardReqeust: continue: send payment") + + // Send payment to merchant + do { + try await business.sendManager.payUnsolicitedInvoice( + invoice: cardRequest.invoice, + metadata: WalletPaymentMetadata.withCard(card.id) + ) + } catch { + log.error("peer.payUnsolicitedInvoice(): error: \(error)") + } + } + } + } + // -------------------------------------------------- // MARK: Utils // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/officers/PushManager.swift b/phoenix-ios/phoenix-ios/officers/PushManager.swift index acdb34fa2..2808b25fd 100644 --- a/phoenix-ios/phoenix-ios/officers/PushManager.swift +++ b/phoenix-ios/phoenix-ios/officers/PushManager.swift @@ -27,6 +27,10 @@ class PushManager { switch push { case .fcm(let notification): processRemoteNotification_fcm(notification, completionHandler) + case .lnurlWithdraw(let notification): + Task { + await processRemoteNotification_aws_withdraw(notification, completionHandler) + } } } else { @@ -66,6 +70,87 @@ class PushManager { } } + @MainActor + private static func processRemoteNotification_aws_withdraw( + _ request: LnurlWithdrawNotification, + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) async { + + log.trace(#function) + + let result = await Biz.business.checkWithdrawRequest(request.toWithdrawRequest()) + + switch result { + case .failure(let error): + log.error("\(#function): error: \(error.description)") + return reject(request, error, completionHandler) + + case .success(let status): + switch status { + case .abortHandledElsewhere: + log.warning("\(#function): abort: handled elsewhere") + return invoke(completionHandler, .newData) + + case .continueAndSendPayment(let card, _, _): + log.debug("\(#function): continue: send payment") + + guard + let peer = Biz.business.peerManager.peerStateValue(), + let defaultTrampolineFees = peer.walletParams.trampolineFees.first + else { + return reject( + request, + .internalError(card: card, details: "peer is nil"), + completionHandler + ) + } + + do { + try await Biz.business.sendManager.payBolt11Invoice( + amountToSend : request.invoiceAmount, + trampolineFees : defaultTrampolineFees, + invoice : request.invoice, + metadata : WalletPaymentMetadata.withCard(card.id) + ) + } catch { + log.error("SendManager.payBolt11Invoice(): threw error: \(error)") + return reject( + request, + .internalError(card: card, details: "payBolt11Invoice failed"), + completionHandler + ) + } + + return accept(request, completionHandler) + } // + } // + } + + private static func reject( + _ request : LnurlWithdrawNotification, + _ error : WithdrawRequestError, + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + log.trace("reject(\(error.description))") + + Task { + let _ = await request.postResponse(errorReason: error.description) + invoke(completionHandler, .newData) + } + } + + private static func accept( + _ request: LnurlWithdrawNotification, + _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + log.trace(#function) + + Task { + let _ = await request.postResponse(errorReason: nil) + invoke(completionHandler, .newData) + } + } + private static func invoke( _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void, _ result: UIBackgroundFetchResult diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift index 9e9ceaf24..95f8da95c 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs+BackupTransactions.swift @@ -109,6 +109,16 @@ class Prefs_BackupTransactions { } } + var hasDownloadedCards: Bool { + get { + maybeLogDefaultAccess(#function) + return defaults.bool(forKey: Key.hasDownloadedCards.value(id)) + } + set { + defaults.set(newValue, forKey: Key.hasDownloadedCards.value(id)) + } + } + var hasReUploadedPayments: Bool { get { maybeLogDefaultAccess(#function) diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs+Keys.swift b/phoenix-ios/phoenix-ios/prefs/Prefs+Keys.swift index 45cf52a3f..c1bb8d6a0 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs+Keys.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs+Keys.swift @@ -18,6 +18,8 @@ enum PrefsKey: CaseIterable { case doNotShowChannelImpactWarning case watchTower_lastAttemptDate case watchTower_lastAttemptFailed + case pushTokenRegistration + case lnurlWithdrawRegistration // BackupSeed: case backupSeed_enabled case backupSeed_hasUploadedSeed @@ -30,6 +32,7 @@ enum PrefsKey: CaseIterable { case recordZoneCreated case hasDownloadedPayments case hasDownloadedContacts + case hasDownloadedCards case hasReUploadedPayments // Global: case theme @@ -54,6 +57,8 @@ enum PrefsKey: CaseIterable { case .doNotShowChannelImpactWarning : return "doNotShowChannelImpactWarning" case .watchTower_lastAttemptDate : return "watchTower_lastAttemptDate" case .watchTower_lastAttemptFailed : return "watchTower_lastAttemptFailed" + case .pushTokenRegistration : return "pushTokenRegistration" + case .lnurlWithdrawRegistration : return "lnurlWithdrawRegistration" // BackupSeed: case .backupSeed_enabled : return "backupSeed_enabled" case .backupSeed_hasUploadedSeed : return "backupSeed_hasUploadedSeed" @@ -66,6 +71,7 @@ enum PrefsKey: CaseIterable { case .recordZoneCreated : return "hasCKRecordZone_v2" case .hasDownloadedPayments : return "hasDownloadedCKRecords" case .hasDownloadedContacts : return "hasDownloadedContacts_v2" + case .hasDownloadedCards : return "hasDownloadedCards" case .hasReUploadedPayments : return "hasReUploadedPayments" // Global: case .theme : return "theme" @@ -94,6 +100,8 @@ enum PrefsKey: CaseIterable { case .doNotShowChannelImpactWarning : return .wallet case .watchTower_lastAttemptDate : return .wallet case .watchTower_lastAttemptFailed : return .wallet + case .pushTokenRegistration : return .wallet + case .lnurlWithdrawRegistration : return .wallet // BackupSeed: case .backupSeed_enabled : return .backupSeed case .backupSeed_hasUploadedSeed : return .backupSeed @@ -106,6 +114,7 @@ enum PrefsKey: CaseIterable { case .recordZoneCreated : return .backupTransactions case .hasDownloadedPayments : return .backupTransactions case .hasDownloadedContacts : return .backupTransactions + case .hasDownloadedCards : return .backupTransactions case .hasReUploadedPayments : return .backupTransactions // Global: case .theme : return .global diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs+Wallet.swift b/phoenix-ios/phoenix-ios/prefs/Prefs+Wallet.swift index 197d08ca2..05503785c 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs+Wallet.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs+Wallet.swift @@ -190,6 +190,27 @@ class Prefs_Wallet { } } + var pushTokenRegistration: PushTokenRegistration? { + get { + maybeLogDefaultAccess(#function) + return defaults.data(forKey: Key.pushTokenRegistration.value(id))?.jsonDecode() + } + set { + defaults.set(newValue?.jsonEncode(), forKey: Key.pushTokenRegistration.value(id)) + } + } + + var lnurlWithdrawRegistration: LnurlWithdrawRegistration? { + get { + maybeLogDefaultAccess(#function) + return defaults.data(forKey: Key.lnurlWithdrawRegistration.value(id))?.jsonDecode() + } + set { + defaults.set(newValue?.jsonEncode(), forKey: Key.lnurlWithdrawRegistration.value(id)) + } + } + + // -------------------------------------------------- // MARK: Wallet State // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift b/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift index 87cbc356c..8bd0045af 100644 --- a/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift +++ b/phoenix-ios/phoenix-ios/prefs/UserDefaults+Codable.swift @@ -58,6 +58,18 @@ enum PushPermissionQuery: String, Codable { case userAccepted } +struct PushTokenRegistration: Equatable, Codable { + let pushToken: String + let nodeIdHash: String + let registrationDate: Date +} + +struct LnurlWithdrawRegistration: Equatable, Codable { + let hexAddr: String + let nodeIdHash: String + let registrationDate: Date +} + struct ElectrumConfigPrefs: Equatable, Codable, CustomStringConvertible { let host: String let port: UInt16 diff --git a/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Cards.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Cards.swift new file mode 100644 index 000000000..d69705f55 --- /dev/null +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Cards.swift @@ -0,0 +1,811 @@ +import Foundation +import CloudKit +import CryptoKit +import PhoenixShared + +fileprivate let filename = "SyncBackupManager+Cards" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +fileprivate let cards_record_table_name = "cards" +fileprivate let cards_record_column_data = "encryptedData" + +fileprivate struct DownloadedCard { + let record: CKRecord + let card: BoltCardInfo +} + +fileprivate struct UploadCardsOperationInfo { + let batch: FetchCardsQueueBatchResult + + let recordsToSave: [CKRecord] + let recordIDsToDelete: [CKRecord.ID] + + let reverseMap: [CKRecord.ID: Lightning_kmpUUID] + + var completedRowids: [Int64] = [] + + var partialFailures: [Lightning_kmpUUID: CKError?] = [:] + + var savedRecords: [CKRecord] = [] + var deletedRecordIds: [CKRecord.ID] = [] +} + +extension SyncBackupManager { + + func startCardsQueueCountMonitor() { + log.trace(#function) + + let queueCountSequnece = cloudKitDb.cards.queueCountSequence() + Task { @MainActor [weak self] in + for await count in queueCountSequnece { + self?.queueCountChanged(count) + } + }.store(in: &cancellables) + } + + // ---------------------------------------- + // MARK: Notifications + // ---------------------------------------- + + private func queueCountChanged(_ queueCount: Int64) { + log.trace("cards.queueCountChanged(): count = \(queueCount)") + + let count = Int(clamping: queueCount) + Task { + if let newState = await self.actor.cardsQueueCountChanged(count, wait: nil) { + self.handleNewState(newState) + } + } + } + + // ---------------------------------------- + // MARK: IO + // ---------------------------------------- + + func downloadCards(_ downloadProgress: SyncBackupManager_State_Downloading) { + log.trace("downloadCards()") + + Task { + + // Step 1 of 4: + // + // We are downloading items from newest to oldest. + // So first we fetch the oldest item date in the table (if there is one) + + let millis: KotlinLong? = try await Task { @MainActor in + return try await self.cloudKitDb.cards.fetchOldestCreation() + }.value + + let oldestCreationDate = millis?.int64Value.toDate(from: .milliseconds) + downloadProgress.setCards_oldestCompletedDownload(oldestCreationDate) + + /** + * NOTE: + * If we want to report proper progress (via `SyncBackupManager_State_Downloading`), + * then we need to know the total number of records to be downloaded from the cloud. + * + * However, there's a minor problem here: + * CloudKit doesn't support aggregate queries ! + * + * So we cannot simply say: SELECT COUNT(*) + * + * Our only option (as far as I'm aware of), + * is to fetch the metadata for every record in the cloud. + * we would have to do this via recursive batch fetching, + * and counting the downloaded records as they stream in. + * + * The big downfall of this approach is that we end up downloading + * the CKRecord metadata 2 times for every record :( + * + * - first just to count the number of records + * - and again when we fetch the full record (with encrypted blob) + * + * Given this bad situation (Bad Apple), + * our current choice is to sacrifice the progress details. + */ + + let privateCloudDatabase = CKContainer.default().privateCloudDatabase + let zoneID = self.recordZoneID() + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = true + + do { + try await privateCloudDatabase.configuredWith(configuration: configuration) { database in + + // Step 2 of 4: + // + // Execute a CKQuery to download a batch of payments from the cloud. + // There may be multiple batches available for download. + + let predicate: NSPredicate + if let oldestCreationDate { + predicate = NSPredicate(format: "creationDate < %@", oldestCreationDate as CVarArg) + } else { + predicate = NSPredicate(format: "TRUEPREDICATE") + } + + let query = CKQuery( + recordType: cards_record_table_name, + predicate: predicate + ) + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false) + ] + + var done = false + var batch = 0 + var cursor: CKQueryOperation.Cursor? = nil + + while !done { + + // For the first batch, we want to quickly fetch an item from the cloud, + // and add it to the database. The faster the better, this way the user + // knows the app is restoring his/her cards. + // + // After that, we can slowly increase the batch size, + // as the user becomes aware of what's happening. + + let resultsLimit: Int + switch batch { + case 0 : resultsLimit = 1 + case 1 : resultsLimit = 2 + case 2 : resultsLimit = 3 + case 3 : resultsLimit = 4 + default : resultsLimit = 8 + } + + log.trace("downloadCards(): batchFetch: requesting \(resultsLimit)") + + let results: [(CKRecord.ID, Result)] + if let prvCursor = cursor { + (results, cursor) = try await database.records( + continuingMatchFrom: prvCursor, + resultsLimit: resultsLimit + ) + } else { + (results, cursor) = try await database.records( + matching: query, + inZoneWith: zoneID, + resultsLimit: resultsLimit + ) + } + + var items: [DownloadedCard] = [] + for (_, result) in results { + if case .success(let record) = result { + let card = self.decryptAndDeserializeCard(record) + if let card { + items.append(DownloadedCard( + record: record, + card: card + )) + } + } + } + + log.trace("downloadCards(): batchFetch: received \(items.count)") + + // Step 3 of 4: + // + // Save the downloaded results to the database. + + try await Task { @MainActor [items] in + + var oldest: Date? = nil + + var rows: [BoltCardInfo] = [] + var metadataMap: [Lightning_kmpUUID: CloudKitCardsDb.MetadataRow] = [:] + + for item in items { + + rows.append(item.card) + + let cardId = item.card.id + let creationDate = item.record.creationDate ?? Date() + let creation = self.dateToMillis(creationDate) + let metadata = self.metadataForRecord(item.record) + + metadataMap[cardId] = CloudKitCardsDb.MetadataRow( + recordCreation: creation, + recordBlob: metadata.toKotlinByteArray() + ) + + if let prv = oldest { + if creationDate < prv { + oldest = creationDate + } + } else { + oldest = creationDate + } + } + + log.trace("downloadCards(): cloudKitDb.updateRows()...") + + try await self.cloudKitDb.cards.updateRows( + downloadedCards: rows, + updateMetadata: metadataMap + ) + + downloadProgress.cards_finishBatch(completed: items.count, oldest: oldest) + + }.value + // + + if (cursor == nil) { + log.trace("downloadCards(): moreInCloud = false") + done = true + } else { + log.trace("downloadCards(): moreInCloud = true") + batch += 1 + } + + } // + } // + + log.trace("downloadCards(): enqueueMissingItems()...") + + // Step 4 of 4: + // + // There may be items that we've added to the database since we started the download process. + // So we enqueue these for upload now. + + try await Task { @MainActor in + try await self.cloudKitDb.cards.enqueueMissingItems() + }.value + + log.trace("downloadCards(): finish: success") + + prefs.backupTransactions.hasDownloadedCards = true + self.consecutiveErrorCount = 0 + + if let newState = await self.actor.didDownloadCards() { + self.handleNewState(newState) + } + + } catch { + + log.error("downloadCards(): error: \(error)") + self.handleError(error) + } + + } // + } + + /// The upload task performs the following tasks: + /// - extract rows from the database that need to be uploaded + /// - serialize & encrypt the data + /// - upload items to the user's private cloud database + /// - remove the uploaded items from the queue + /// - repeat as needed + /// + func uploadCards(_ uploadProgress: SyncBackupManager_State_Uploading) { + log.trace("uploadCards()") + + let prepareUpload = {( + batch: FetchCardsQueueBatchResult + ) -> UploadCardsOperationInfo in + + log.trace("uploadCards(): prepareUpload()") + + var recordsToSave = [CKRecord]() + var recordIDsToDelete = [CKRecord.ID]() + + var reverseMap = [CKRecord.ID: Lightning_kmpUUID]() + + // NB: batch.rowidMap may contain the same cardId multiple times. + // And if we include the same record multiple times in the CKModifyRecordsOperation, + // then the operation will fail. + // + for cardId in batch.uniqueCardIds() { + + var existingRecord: CKRecord? = nil + if let metadata = batch.metadataMap[cardId] { + + let data = metadata.toSwiftData() + existingRecord = self.recordFromMetadata(data) + } + + if let cardInfo = batch.rowMap[cardId] { + + if let ciphertext = self.serializeAndEncryptCard(cardInfo) { + + let record = existingRecord ?? CKRecord( + recordType: cards_record_table_name, + recordID: self.recordID(cardId: cardId) + ) + + record[cards_record_column_data] = ciphertext + + recordsToSave.append(record) + reverseMap[record.recordID] = cardId + } + + } else { + + // The card has been deleted from the local database. + // So we're going to delete it from the cloud database (if it exists there). + + let recordID = existingRecord?.recordID ?? self.recordID(cardId: cardId) + + recordIDsToDelete.append(recordID) + reverseMap[recordID] = cardId + } + } + + var opInfo = UploadCardsOperationInfo( + batch: batch, + recordsToSave: recordsToSave, + recordIDsToDelete: recordIDsToDelete, + reverseMap: reverseMap + ) + + // Edge-case: A rowid wasn't able to be converted to a UUID. + // + // So the rowid is not represented in either `rowidMap` or `uniqueCardIds()`. + // Nor is it reprensented in `recordsToSave` or `recordIDsToDelete`. + // + // The end result is that we have an empty operation. + // And we won't remove the rowid from the database either, creating an infinite loop. + // + // So we add a sanity check here. + + for rowid in batch.rowids { + if batch.rowidMap[rowid] == nil { + log.warning("Malformed UUID in cards_queue") + opInfo.completedRowids.append(rowid) + } + } + + return opInfo + + } // + + let performUpload = {( + opInfo: UploadCardsOperationInfo + ) async throws -> UploadCardsOperationInfo in + + log.trace("uploadCards(): performUpload()") + log.trace("opInfo.recordsToSave.count = \(opInfo.recordsToSave.count)") + log.trace("opInfo.recordIDsToDelete.count = \(opInfo.recordIDsToDelete.count)") + + if Task.isCancelled { + throw CKError(.operationCancelled) + } + + let container = CKContainer.default() + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = self.prefs.backupTransactions.useCellular + + return try await container.privateCloudDatabase.configuredWith(configuration: configuration) { database in + + let (saveResults, deleteResults) = try await database.modifyRecords( + saving: opInfo.recordsToSave, + deleting: opInfo.recordIDsToDelete, + savePolicy: CKModifyRecordsOperation.RecordSavePolicy.ifServerRecordUnchanged, + atomically: false + ) + + // saveResults: [CKRecord.ID : Result] + // deleteResults: [CKRecord.ID : Result] + + var nextOpInfo = opInfo + + var accountFailure: CKError? = nil + var recordIDsToFetch: [CKRecord.ID] = [] + + for (recordID, result) in saveResults { + + guard let cardId = opInfo.reverseMap[recordID] else { + continue + } + + switch result { + case .success(let record): + nextOpInfo.savedRecords.append(record) + + for rowid in nextOpInfo.batch.rowidsMatching(cardId) { + nextOpInfo.completedRowids.append(rowid) + } + + case .failure(let error): + if let recordError = error as? CKError { + + nextOpInfo.partialFailures[cardId] = recordError + + // If this is a standard your-changetag-was-out-of-date message from the server, + // then we just need to fetch the latest CKRecord metadata from the cloud, + // and then re-try our upload. + if recordError.errorCode == CKError.serverRecordChanged.rawValue { + recordIDsToFetch.append(recordID) + } else if recordError.errorCode == CKError.accountTemporarilyUnavailable.rawValue { + accountFailure = recordError + } + } + } // + } // + + for (recordID, result) in deleteResults { + + guard let cardId = opInfo.reverseMap[recordID] else { + continue + } + + switch result { + case .success(_): + nextOpInfo.deletedRecordIds.append(recordID) + + for rowid in nextOpInfo.batch.rowidsMatching(cardId) { + nextOpInfo.completedRowids.append(rowid) + } + + case .failure(let error): + if let recordError = error as? CKError { + + nextOpInfo.partialFailures[cardId] = recordError + + if recordError.errorCode == CKError.accountTemporarilyUnavailable.rawValue { + accountFailure = recordError + } + } + } // + } // + + if let accountFailure { + // We received one or more `accountTemporarilyUnavailable` errors. + // We have special error handling code for this. + throw accountFailure + + } else if !recordIDsToFetch.isEmpty { + // One or more records was out-of-date (as compared with the server version). + // So we need to refetch those records from the server. + + if Task.isCancelled { + throw CKError(.operationCancelled) + } + + let results: [CKRecord.ID : Result] = try await database.records( + for: recordIDsToFetch, + desiredKeys: [] // fetch only basic CKRecord metadata + ) + + let fetchedRecords: [CKRecord] = results.values.compactMap { result in + return try? result.get() + } + + // We successfully fetched the latest CKRecord(s) from the server. + // We add to nextOpInfo.savedRecords, which will write the CKRecord to the database. + // So on the next upload attempt, we should have the latest version. + + nextOpInfo.savedRecords.append(contentsOf: fetchedRecords) + + } else { + // Every payment in the batch was successful + } + + return nextOpInfo + + } // + + } // + + let updateDatabase = {( + opInfo: UploadCardsOperationInfo + ) async throws -> Void in + + log.trace("uploadCards(): updateDatabase()") + + var deleteFromQueue = [KotlinLong]() + var deleteFromMetadata = [Lightning_kmpUUID]() + var updateMetadata = [Lightning_kmpUUID: CloudKitCardsDb.MetadataRow]() + + for (rowid) in opInfo.completedRowids { + deleteFromQueue.append(KotlinLong(longLong: rowid)) + } + for recordId in opInfo.deletedRecordIds { + if let cardId = opInfo.reverseMap[recordId] { + deleteFromMetadata.append(cardId) + } + } + for record in opInfo.savedRecords { + if let cardId = opInfo.reverseMap[record.recordID] { + + let creation = self.dateToMillis(record.creationDate ?? Date()) + let metadata = self.metadataForRecord(record) + + updateMetadata[cardId] = CloudKitCardsDb.MetadataRow( + recordCreation: creation, + recordBlob: metadata.toKotlinByteArray() + ) + } + } + + // Handle partial failures + let partialFailures: [String: CKError?] = opInfo.partialFailures.mapKeys { $0.id } + + for cardIdStr in self.updateConsecutivePartialFailures(partialFailures) { + for rowid in opInfo.batch.rowidsMatching(cardIdStr) { + deleteFromQueue.append(KotlinLong(longLong: rowid)) + } + } + + log.debug("deleteFromQueue.count = \(deleteFromQueue.count)") + log.debug("deleteFromMetadata.count = \(deleteFromMetadata.count)") + log.debug("updateMetadata.count = \(updateMetadata.count)") + + try await Task { @MainActor [deleteFromQueue, deleteFromMetadata, updateMetadata] in + try await self.cloudKitDb.cards.updateRows( + deleteFromQueue: deleteFromQueue, + deleteFromMetadata: deleteFromMetadata, + updateMetadata: updateMetadata + ) + }.value + + } // + + let finish = {( + result: Result + ) async -> Void in + + switch result { + case .success: + log.trace("uploadCards(): finish(): success") + + self.consecutiveErrorCount = 0 + if let newState = await self.actor.didUploadItems() { + self.handleNewState(newState) + } + + case .failure(let error): + log.trace("uploadCards(): finish(): failure") + self.handleError(error) + } + + } // + + Task { + log.trace("uploadCards(): starting task...") + + do { + // Step 1 of 4: + // + // Check the `cloudkit_cards_queue` table, + // to see if there's anything we need to upload. + + let result: CloudKitCardsDb.FetchQueueBatchResult = try await Task { @MainActor in + return try await self.cloudKitDb.cards.fetchQueueBatch(limit: 1) + }.value + + let batch = result.convertToSwift() + + log.debug("uploadCards(): batch.rowids.count = \(batch.rowids.count)") + log.debug("uploadCards(): batch.rowidMap.count = \(batch.rowidMap.count)") + log.debug("uploadCards(): batch.rowMap.count = \(batch.rowMap.count)") + log.debug("uploadCards(): batch.metadataMap.count = \(batch.metadataMap.count)") + + if batch.rowids.isEmpty { + // There's nothing queued for upload, so we're done. + + // Bug Fix / Workaround: + // The queueCountPublisher isn't firing reliably, and I'm not sure why... + // + // This can lead to the following infinite loop: + // - queueCountPublisher fires and reports a non-zero count + // - the actor's corresponding queueCount is updated + // - the uploadTask is evetually triggered + // - the item(s) are properly uploaded, and the rows are deleted from the queue + // - queueCountPublisher does NOT properly fire + // - the uploadTask is triggered again + // - it finds zero rows to upload, but actor's queueCount remains unchanged + // - the uploadTask is triggered again + // - ... + // + if let newState = await self.actor.cardsQueueCountChanged(0, wait: nil) { + self.handleNewState(newState) + } + return await finish(.success) + } + + // Step 2 of 4: + // + // Serialize and encrypt the card information. + // Then encapsulate the encrypted blob into a CKRecord. + // And prepare a full CKModifyRecordsOperation for upload. + + var opInfo = prepareUpload(batch) + + // Step 3 of 4: + // + // Perform the cloud operation. + + let inFlightCount = opInfo.recordsToSave.count + opInfo.recordIDsToDelete.count + if inFlightCount == 0 { + // Edge case: there are no cloud tasks to perform. + // We have to skip the upload, because it will fail if given an empty set of tasks. + } else { + opInfo = try await performUpload(opInfo) + } + + // Step 4 of 4: + // + // Process the upload results. + + try await updateDatabase(opInfo) + + // Done ! + + uploadProgress.completeCards_inFlight(inFlightCount) + return await finish(.success) + + } catch { + return await finish(.failure(error)) + } + } // + } + + // ---------------------------------------- + // MARK: Record ID + // ---------------------------------------- + + private func recordID(cardId: Lightning_kmpUUID) -> CKRecord.ID { + + // The recordID is: + // - deterministic => by hashing the cardId + // - secure => by mixing in the nodeIdHash (nodeKey.publicKey.hash160) + + let prefix = walletInfo.nodeIdHash.data(using: .utf8)! + let suffix = cardId.description().data(using: .utf8)! + + let hashMe = prefix + suffix + let digest = SHA256.hash(data: hashMe) + let hash = digest.toHex(.lowerCase) + + return CKRecord.ID(recordName: hash, zoneID: recordZoneID()) + } + + // ---------------------------------------- + // MARK: Utilities + // ---------------------------------------- + + /// Performs all of the following: + /// - serializes item (CBOR) + /// - encrypts the blob using the cloudKey + /// + private func serializeAndEncryptCard( + _ card: BoltCardInfo + ) -> Data? { + + let wrapper = CloudCard.V0(card: card) + let cbor = wrapper.cborSerialize().toSwiftData() + + #if DEBUG + let jsonData = wrapper.jsonSerialize().toSwiftData() + let jsonStr = String(data: jsonData, encoding: .utf8) + log.debug("Uploading record (JSON representation):\n\(jsonStr ?? "")") + #endif + + let cleartext: Data = cbor + var ciphertext: Data? = nil + do { + let box = try ChaChaPoly.seal(cleartext, using: self.cloudKey) + ciphertext = box.combined + + } catch { + log.error("Error encrypting row with ChaChaPoly: \(String(describing: error))") + } + + if let ciphertext { + return ciphertext + } else { + return nil + } + } + + private func decryptAndDeserializeCard( + _ record: CKRecord + ) -> BoltCardInfo? { + + log.debug("Received record:") + log.debug(" - recordID: \(record.recordID)") + log.debug(" - creationDate: \(record.creationDate ?? Date.distantPast)") + + guard let ciphertext = record[cards_record_column_data] as? Data else { + log.error("Missing column.data: skipping \(record.recordID)") + return nil + } + + let cleartext: Data + do { + let box = try ChaChaPoly.SealedBox(combined: ciphertext) + cleartext = try ChaChaPoly.open(box, using: self.cloudKey) + } catch { + log.error("Error decrypting record.data: skipping \(record.recordID)") + return nil + } + + let card: BoltCardInfo? + do { + let cleartext_kotlin = cleartext.toKotlinByteArray() + card = try CloudCard.companion.cborDeserializeAndUnwrap(blob: cleartext_kotlin) + + } catch { + log.error("Error deserializing record.data: skipping \(record.recordID)") + return nil + } + + return card + } + + // ---------------------------------------- + // MARK: Debugging + // ---------------------------------------- + #if DEBUG + + func listAllCards() { + log.trace("listAllCards()") + + let query = CKQuery( + recordType: cards_record_table_name, + predicate: NSPredicate(format: "TRUEPREDICATE") + ) + query.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false) + ] + + let operation = CKQueryOperation(query: query) + operation.zoneID = recordZoneID() + + recursiveListCardsBatch(operation: operation) + } + + private func recursiveListCardsBatch(operation: CKQueryOperation) { + log.trace("recursiveListCardsBatch()") + + operation.recordMatchedBlock = {(recordID: CKRecord.ID, result: Result) in + + if let record = try? result.get() { + + log.debug("Received record:") + log.debug(" - recordID: \(record.recordID)") + log.debug(" - creationDate: \(record.creationDate ?? Date.distantPast)") + + if let data = record[cards_record_column_data] as? Data { + log.debug(" - data.count: \(data.count)") + } else { + log.debug(" - data: ?") + } + } + } + + operation.queryResultBlock = {(result: Result) in + + switch result { + case .success(let cursor): + if let cursor = cursor { + log.debug("recursiveListCardsBatch: Continuing with cursor...") + self.recursiveListCardsBatch(operation: CKQueryOperation(cursor: cursor)) + + } else { + log.debug("recursiveListCardsBatch: Complete") + } + + case .failure(let error): + log.debug("recursiveListCardsBatch: error: \(error)") + } + } + + let configuration = CKOperation.Configuration() + configuration.allowsCellularAccess = true + + operation.configuration = configuration + + CKContainer.default().privateCloudDatabase.add(operation) + } + + #endif +} diff --git a/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift index ad5f0ae42..17e69106f 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager+Contacts.swift @@ -74,8 +74,8 @@ extension SyncBackupManager { // Step 1 of 4: // - // We are downloading payments from newest to oldest. - // So first we fetch the oldest payment date in the table (if there is one) + // We are downloading items from newest to oldest. + // So first we fetch the oldest item date in the table (if there is one) let millis: KotlinLong? = try await Task { @MainActor in return try await self.cloudKitDb.contacts.fetchOldestCreation() @@ -120,7 +120,7 @@ extension SyncBackupManager { // Step 2 of 4: // - // Execute a CKQuery to download a batch of payments from the cloud. + // Execute a CKQuery to download a batch of items from the cloud. // There may be multiple batches available for download. let predicate: NSPredicate @@ -252,7 +252,7 @@ extension SyncBackupManager { // Step 4 of 4: // - // There may be payments that we've added to the database since we started the download process. + // There may be items that we've added to the database since we started the download process. // So we enqueue these for upload now. try await Task { @MainActor in @@ -681,8 +681,7 @@ extension SyncBackupManager { // ---------------------------------------- /// Performs all of the following: - /// - serializes incoming/outgoing payment into JSON - /// - adds randomized padding to obfuscate payment type + /// - serializes item (CBOR) /// - encrypts the blob using the cloudKey /// private func serializeAndEncryptContact( diff --git a/phoenix-ios/phoenix-ios/sync/SyncBackupManager.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager.swift index 1278c4f78..92d54f40b 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncBackupManager.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager.swift @@ -95,7 +95,8 @@ class SyncBackupManager: @unchecked Sendable { isEnabled: prefs.backupTransactions.isEnabled, recordZoneCreated: prefs.backupTransactions.recordZoneCreated, hasDownloadedPayments: prefs.backupTransactions.hasDownloadedPayments, - hasDownloadedContacts: prefs.backupTransactions.hasDownloadedContacts + hasDownloadedContacts: prefs.backupTransactions.hasDownloadedContacts, + hasDownloadedCards: prefs.backupTransactions.hasDownloadedCards ) waitForDatabases() @@ -258,28 +259,36 @@ class SyncBackupManager: @unchecked Sendable { log.trace("state = \(newState)") switch newState { - case .updatingCloud(let details): - switch details.kind { - case .creatingRecordZone: - createRecordZone(details) - case .deletingRecordZone: - deleteRecordZone(details) - } - case .downloading(let details): - if details.needsDownloadPayments { - downloadPayments(details) - } - if details.needsDownloadContacts { - downloadContacts(details) - } - case .uploading(let details): - if details.payments_pendingCount > 0 { - uploadPayments(details) - } else { - uploadContacts(details) - } - default: - break + case .updatingCloud(let details): + switch details.kind { + case .creatingRecordZone: + createRecordZone(details) + case .deletingRecordZone: + deleteRecordZone(details) + } + + case .downloading(let details): + if details.needsDownloadPayments { + downloadPayments(details) + } + if details.needsDownloadContacts { + downloadContacts(details) + } + if details.needsDownloadCards { + downloadCards(details) + } + + case .uploading(let details): + if details.payments_pendingCount > 0 { + uploadPayments(details) + } else if details.contacts_pendingCount > 0 { + uploadContacts(details) + } else { + uploadCards(details) + } + + default: + break } publishNewState(newState) @@ -306,6 +315,7 @@ class SyncBackupManager: @unchecked Sendable { self.startPaymentsQueueCountMonitor() self.startPaymentsMigrations() self.startContactsQueueCountMonitor() + self.startCardsQueueCountMonitor() self.startPreferencesMonitor() } diff --git a/phoenix-ios/phoenix-ios/sync/SyncBackupManager_Actor.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_Actor.swift index d14ccfea1..882c21621 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncBackupManager_Actor.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_Actor.swift @@ -20,11 +20,13 @@ actor SyncBackupManager_Actor { private var isEnabled: Bool private var needsCreateRecordZone: Bool private var needsDeleteRecordZone: Bool - private var needsDownloadPayments = false - private var needsDownloadContacts = false + private var needsDownloadPayments: Bool + private var needsDownloadContacts: Bool + private var needsDownloadCards: Bool private var paymentsQueueCount: Int = 0 private var contactsQueueCount: Int = 0 + private var cardsQueueCount: Int = 0 private var state: SyncBackupManager_State private var pendingSettings: SyncBackupManager_PendingSettings? = nil @@ -37,18 +39,21 @@ actor SyncBackupManager_Actor { isEnabled: Bool, recordZoneCreated: Bool, hasDownloadedPayments: Bool, - hasDownloadedContacts: Bool + hasDownloadedContacts: Bool, + hasDownloadedCards: Bool ) { self.isEnabled = isEnabled if isEnabled { needsCreateRecordZone = !recordZoneCreated needsDownloadPayments = !hasDownloadedPayments needsDownloadContacts = !hasDownloadedContacts + needsDownloadCards = !hasDownloadedCards needsDeleteRecordZone = false } else { needsCreateRecordZone = false needsDownloadPayments = false needsDownloadContacts = false + needsDownloadCards = false needsDeleteRecordZone = recordZoneCreated } @@ -186,6 +191,34 @@ actor SyncBackupManager_Actor { } } + func cardsQueueCountChanged( + _ count: Int, + wait: SyncBackupManager_State_Waiting? + ) -> SyncBackupManager_State? { + + log.trace("cardsQueueCountChanged(\(count))") + + cardsQueueCount = count + guard count > 0 else { + return nil + } + switch state { + case .uploading(let details): + details.setCards_totalCount(count) + return nil + case .synced: + if let wait = wait { + log.debug("state = waiting(randomizedUploadDelay)") + state = .waiting(details: wait) + return state + } else { + return simplifiedStateFlow() + } + default: + return nil + } + } + func didCreateRecordZone() -> SyncBackupManager_State? { log.trace("didCreateRecordZone()") @@ -224,7 +257,7 @@ actor SyncBackupManager_Actor { log.trace("didDownloadPayments()") needsDownloadPayments = false - if needsDownloadContacts { + if needsDownloadContacts || needsDownloadCards { return nil } else { switch state { @@ -240,7 +273,23 @@ actor SyncBackupManager_Actor { log.trace("didDownloadContacts()") needsDownloadContacts = false - if needsDownloadPayments { + if needsDownloadPayments || needsDownloadCards { + return nil + } else { + switch state { + case .downloading: + return simplifiedStateFlow() + default: + return nil + } + } + } + + func didDownloadCards() -> SyncBackupManager_State? { + log.trace("didDownloadCards()") + + needsDownloadCards = false + if needsDownloadPayments || needsDownloadContacts { return nil } else { switch state { @@ -367,6 +416,7 @@ actor SyncBackupManager_Actor { needsCreateRecordZone = true needsDownloadPayments = true needsDownloadContacts = true + needsDownloadCards = true needsDeleteRecordZone = false switch state { @@ -402,6 +452,7 @@ actor SyncBackupManager_Actor { needsCreateRecordZone = false needsDownloadPayments = false needsDownloadContacts = false + needsDownloadCards = false needsDeleteRecordZone = true switch state { @@ -461,15 +512,17 @@ actor SyncBackupManager_Actor { } else if isEnabled { if needsCreateRecordZone { state = .updatingCloud_creatingRecordZone() - } else if needsDownloadPayments || needsDownloadContacts { + } else if needsDownloadPayments || needsDownloadContacts || needsDownloadCards { state = .downloading(details: SyncBackupManager_State_Downloading( needsDownloadPayments: needsDownloadPayments, - needsDownloadContacts: needsDownloadContacts + needsDownloadContacts: needsDownloadContacts, + needsDownloadCards: needsDownloadCards )) - } else if paymentsQueueCount > 0 || contactsQueueCount > 0 { + } else if paymentsQueueCount > 0 || contactsQueueCount > 0 || cardsQueueCount > 0 { state = .uploading(details: SyncBackupManager_State_Uploading( payments_totalCount: paymentsQueueCount, - contacts_totalCount: contactsQueueCount + contacts_totalCount: contactsQueueCount, + cards_totalCount: cardsQueueCount )) } else { state = .synced diff --git a/phoenix-ios/phoenix-ios/sync/SyncBackupManager_State.swift b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_State.swift index 458a15eac..e7ed720d7 100644 --- a/phoenix-ios/phoenix-ios/sync/SyncBackupManager_State.swift +++ b/phoenix-ios/phoenix-ios/sync/SyncBackupManager_State.swift @@ -191,6 +191,7 @@ class SyncBackupManager_State_Downloading: ObservableObject, Equatable, @uncheck let needsDownloadPayments: Bool let needsDownloadContacts: Bool + let needsDownloadCards: Bool @Published private(set) var payments_completedCount: Int = 0 @Published private(set) var payments_oldestCompletedDownload: Date? = nil @@ -198,13 +199,17 @@ class SyncBackupManager_State_Downloading: ObservableObject, Equatable, @uncheck @Published private(set) var contacts_completedCount: Int = 0 @Published private(set) var contacts_oldestCompletedDownload: Date? = nil - init(needsDownloadPayments: Bool, needsDownloadContacts: Bool) { + @Published private(set) var cards_completedCount: Int = 0 + @Published private(set) var cards_oldestCompletedDownload: Date? = nil + + init(needsDownloadPayments: Bool, needsDownloadContacts: Bool, needsDownloadCards: Bool) { self.needsDownloadPayments = needsDownloadPayments self.needsDownloadContacts = needsDownloadContacts + self.needsDownloadCards = needsDownloadCards } var completedCount: Int { - return payments_completedCount + contacts_completedCount + return payments_completedCount + contacts_completedCount + cards_completedCount } func setPayments_oldestCompletedDownload(_ date: Date?) { @@ -219,6 +224,12 @@ class SyncBackupManager_State_Downloading: ObservableObject, Equatable, @uncheck } } + func setCards_oldestCompletedDownload(_ date: Date?) { + runOnMainThread { + self.cards_oldestCompletedDownload = date + } + } + func payments_finishBatch(completed: Int, oldest: Date?) { runOnMainThread { self.payments_completedCount += completed @@ -251,6 +262,22 @@ class SyncBackupManager_State_Downloading: ObservableObject, Equatable, @uncheck } } + func cards_finishBatch(completed: Int, oldest: Date?) { + runOnMainThread { + self.cards_completedCount += completed + + if let oldest = oldest { + if let prv = self.cards_oldestCompletedDownload { + if oldest < prv { + self.cards_oldestCompletedDownload = oldest + } + } else { + self.cards_oldestCompletedDownload = oldest + } + } + } + } + static func == (lhs: SyncBackupManager_State_Downloading, rhs: SyncBackupManager_State_Downloading ) -> Bool { @@ -274,37 +301,47 @@ class SyncBackupManager_State_Uploading: ObservableObject, Equatable { @Published private(set) var contacts_inFlightCount: Int = 0 @Published private(set) var contacts_inFlightProgress: Progress? = nil + @Published private(set) var cards_totalCount: Int + @Published private(set) var cards_completedCount: Int = 0 + @Published private(set) var cards_inFlightCount: Int = 0 + @Published private(set) var cards_inFlightProgress: Progress? = nil + private(set) var isCancelled = false private(set) var operation: CKOperation? = nil var totalCount: Int { - return payments_totalCount + contacts_totalCount + return payments_totalCount + contacts_totalCount + cards_totalCount } var completedCount: Int { - return payments_completedCount + contacts_completedCount + return payments_completedCount + contacts_completedCount + cards_completedCount } var inFlightCount: Int { - return payments_inFlightCount + contacts_inFlightCount + return payments_inFlightCount + contacts_inFlightCount + cards_inFlightCount } var inFlightProgress: Progress? { // Note: we only perform one upload at a time (either payments or contacts) - return payments_inFlightProgress ?? contacts_inFlightProgress + return payments_inFlightProgress ?? contacts_inFlightProgress ?? cards_inFlightProgress } var payments_pendingCount: Int { - return payments_totalCount - payments_completedCount + return max(0, payments_totalCount - payments_completedCount) } var contacts_pendingCount: Int { - return contacts_totalCount - contacts_completedCount + return max(0, contacts_totalCount - contacts_completedCount) } - init(payments_totalCount: Int, contacts_totalCount: Int) { + var cards_pendingCount: Int { + return max(0, cards_totalCount - cards_completedCount) + } + + init(payments_totalCount: Int, contacts_totalCount: Int, cards_totalCount: Int) { self.payments_totalCount = payments_totalCount self.contacts_totalCount = contacts_totalCount + self.cards_totalCount = cards_totalCount } func setPayments_totalCount(_ value: Int) { @@ -319,6 +356,12 @@ class SyncBackupManager_State_Uploading: ObservableObject, Equatable { } } + func setCards_totalCount(_ value: Int) { + runOnMainThread { + self.cards_totalCount = value + } + } + func setPayments_inFlight(count: Int, progress: Progress) { runOnMainThread { self.payments_inFlightCount = count @@ -342,6 +385,14 @@ class SyncBackupManager_State_Uploading: ObservableObject, Equatable { } } + func completeCards_inFlight(_ completed: Int) { + runOnMainThread { + self.cards_completedCount += completed + self.cards_inFlightCount = 0 + self.cards_inFlightProgress = nil + } + } + func cancel() { isCancelled = true operation?.cancel() diff --git a/phoenix-ios/phoenix-ios/utils/Currency+CurrencyPrefs.swift b/phoenix-ios/phoenix-ios/utils/Currency+CurrencyPrefs.swift index a5c8dde87..d0a0930a2 100644 --- a/phoenix-ios/phoenix-ios/utils/Currency+CurrencyPrefs.swift +++ b/phoenix-ios/phoenix-ios/utils/Currency+CurrencyPrefs.swift @@ -16,7 +16,7 @@ extension Currency { /// - currencyPrefs: Pass the view's EvironmentObject instance /// - plus: Optionally add an additional Currency to the end of the list /// - static func displayable(currencyPrefs: CurrencyPrefs, plus: Currency? = nil) -> [Currency] { + static func displayable(currencyPrefs: CurrencyPrefs, plus: [Currency]? = nil) -> [Currency] { var all = [Currency](GroupPrefs.current.currencyConverterList) @@ -30,9 +30,11 @@ extension Currency { all.insert(preferredBitcoinUnit, at: 0) } - if let plus = plus { - if !all.contains(plus) { - all.append(plus) + if let plus { + for currency in plus { + if !all.contains(currency) { + all.append(currency) + } } } diff --git a/phoenix-ios/phoenix-ios/utils/Currency.swift b/phoenix-ios/phoenix-ios/utils/Currency.swift index f38a8f08a..e64b6f984 100644 --- a/phoenix-ios/phoenix-ios/utils/Currency.swift +++ b/phoenix-ios/phoenix-ios/utils/Currency.swift @@ -53,4 +53,14 @@ enum Currency: Hashable, Identifiable, CustomStringConvertible { return "fiat(\(currency.shortName))" } } + + static func fromKotlin(_ value: CurrencyUnit) -> Currency { + if let btcUnit = value as? BitcoinUnit { + return .bitcoin(btcUnit) + } + if let fiatCurrency = value as? FiatCurrency { + return .fiat(fiatCurrency) + } + fatalError("Unknown CurrencyUnit: \(value)") + } } diff --git a/phoenix-ios/phoenix-ios/utils/CurrencyAmount.swift b/phoenix-ios/phoenix-ios/utils/CurrencyAmount.swift index 3e890ea38..c426e777f 100644 --- a/phoenix-ios/phoenix-ios/utils/CurrencyAmount.swift +++ b/phoenix-ios/phoenix-ios/utils/CurrencyAmount.swift @@ -1,6 +1,25 @@ import Foundation +import PhoenixShared -struct CurrencyAmount: Equatable { +struct CurrencyAmount: Equatable, Hashable { let currency: Currency let amount: Double + + func toSpendingLimit() -> SpendingLimit { + switch currency { + case .bitcoin(let bitcoinUnit): + return SpendingLimit(currency: bitcoinUnit as CurrencyUnit, amount: amount) + case .fiat(let fiatCurrency): + return SpendingLimit(currency: fiatCurrency as CurrencyUnit, amount: amount) + } + } +} + +extension SpendingLimit { + func toCurrencyAmount() -> CurrencyAmount { + return CurrencyAmount( + currency: Currency.fromKotlin(self.currency), + amount: self.amount + ) + } } diff --git a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift index c4fb7aeca..1eb2fccae 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift @@ -641,6 +641,7 @@ struct ConfigurationList: View { case .finalWallet : newNavLinkTag = .WalletInfo ; delay *= 2 case .appAccess : newNavLinkTag = .AppAccess ; delay *= 1 case .walletMetadata : newNavLinkTag = .WalletMetadata ; delay *= 1 + case .bip353Registration : newNavLinkTag = .Experimental ; delay *= 1 } if let newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/Experimental.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/Experimental.swift index 8d8e32573..3e81c32d9 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/Experimental.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/Experimental.swift @@ -1,5 +1,6 @@ import SwiftUI import PhoenixShared +import CoreNFC fileprivate let filename = "Experimental" #if DEBUG && true @@ -10,21 +11,52 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) struct Experimental: View { + enum NavLinkTag: Hashable, CustomStringConvertible { + case ManageBoltCard(cardInfo: BoltCardInfo, isNewCard: Bool) + + var description: String { + switch self { + case .ManageBoltCard(let info, _) : return "ManageBoltCard(\(info.name))" + } + } + } + @State var address: String? = Keychain.current.getBip353Address() - @State var isClaiming: Bool = false + @State var isClaimingAddress: Bool = false enum ClaimError: Error { case noChannels case timeout } @State var claimError: ClaimError? = nil - @State var claimIndex: Int = 0 + @State var sortedCards: [BoltCardInfo] = [] + @State var archivedCards: [BoltCardInfo] = [] + + @State var isFetchingLnurlwAddr: Bool = false + @State var lnurlwAddrFetchError: Bool = false + + @State var archivedCardsHidden: Bool = true + @State var nfcUnavailable: Bool = false + @State var showHelpSheet: Bool = false + @State var didAppear: Bool = false + + // + @State var navLinkTag: NavLinkTag? = nil + // + @StateObject var toast = Toast() @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var smartModalState: SmartModalState + @EnvironmentObject var navCoordinator: NavigationCoordinator + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + @ViewBuilder var body: some View { @@ -41,18 +73,114 @@ struct Experimental: View { List { section_bip353() + + section_cardsInfo() + if !sortedCards.isEmpty { + section_linkedCards() + } + if !archivedCards.isEmpty { + section_archivedCards() + } + + section_newCard() } .listStyle(.insetGrouped) .listBackgroundColor(.primaryBackground) + .navigationStackDestination(isPresented: navLinkTagBinding()) { // iOS 16 + navLinkView() + } + .navigationStackDestination(for: NavLinkTag.self) { tag in // iOS 17+ + navLinkView(tag) + } + .toolbar { + toolbarItems() + } + .onAppear { + onAppear() + } + .task { + do { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + for await list in cardsDb.cardsListSequence() { + cardsListChanged(list) + } + } catch {} + } + .sheet(isPresented: $showHelpSheet) { + BoltCardsHelp(isShowing: $showHelpSheet) + } } + @ToolbarContentBuilder + func toolbarItems() -> some ToolbarContent { + + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button { + readCard() + } label: { + Label { + Text("Read card…") + } icon: { + Image(systemName: "creditcard") + } + } + .disabled(nfcUnavailable) + + } label: { + Image(systemName: "ellipsis") + } + } + } + + @ViewBuilder + func navLink( + _ tag: NavLinkTag, + label: @escaping () -> Content + ) -> some View where Content: View { + + if #available(iOS 17, *) { + NavigationLink(value: tag, label: label) + } else { + NavigationLink_16( + destination: navLinkView(tag), + tag: tag, + selection: $navLinkTag, + label: label + ) + } + } + + @ViewBuilder + func navLinkView() -> some View { + + if let tag = self.navLinkTag { + navLinkView(tag) + } else { + EmptyView() + } + } + + @ViewBuilder + func navLinkView(_ tag: NavLinkTag) -> some View { + + switch tag { + case .ManageBoltCard(let cardInfo, let isNewCard): + ManageBoltCard(cardInfo: cardInfo, isNewCard: isNewCard) + } + } + + // -------------------------------------------------- + // MARK: Section: BIP 353 + // -------------------------------------------------- + @ViewBuilder func section_bip353() -> some View { Section { VStack(alignment: HorizontalAlignment.leading, spacing: 24) { - Label { + LabelAlignment { section_bip353_info() } icon: { Image(systemName: "at") @@ -80,7 +208,7 @@ struct Experimental: View { VStack(alignment: HorizontalAlignment.leading, spacing: 4) { if let address { - HStack(alignment: VerticalAlignment.center, spacing: 4) { + HStack(alignment: VerticalAlignment.top, spacing: 4) { Text(address) Spacer(minLength: 0) Button { @@ -103,6 +231,7 @@ struct Experimental: View { ) .font(.subheadline) .foregroundColor(.secondary) + .padding(.top, 4) if address != nil { Text( @@ -123,7 +252,7 @@ struct Experimental: View { ZStack(alignment: Alignment.leading) { - if isClaiming { + if isClaimingAddress { Label { Text("") } icon: { @@ -142,7 +271,7 @@ struct Experimental: View { } .buttonStyle(.borderedProminent) .buttonBorderShape(.capsule) - .disabled(isClaiming) + .disabled(isClaimingAddress) Spacer(minLength: 10) } // @@ -176,12 +305,285 @@ struct Experimental: View { } } + // -------------------------------------------------- + // MARK: Section: Cards Info + // -------------------------------------------------- + + @ViewBuilder + func section_cardsInfo() -> some View { + + Section { + VStack(alignment: HorizontalAlignment.center, spacing: 10) { + Text("Link a **physical card** to your Phoenix wallet.") + .multilineTextAlignment(.center) + + Text("Then make **contactless payments** at supporting merchants.") + .multilineTextAlignment(.center) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer(minLength: 0) + Button { + showHelpSheet = true + } label: { + Text("learn more") + .font(.callout) + } + } + .padding(.top, 5) + + } // + .background( + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Image(systemName: "creditcard").resizable().scaledToFit() + Spacer() + Image(systemName: "wave.3.forward").resizable().scaledToFit() + } // + .opacity(0.03) + ) + } header: { + Text("Bolt Cards") + } + } + + // -------------------------------------------------- + // MARK: Section: Linked Cards + // -------------------------------------------------- + + @ViewBuilder + func section_linkedCards() -> some View { + + Section { + ForEach(sortedCards) { cardInfo in + navLink(.ManageBoltCard(cardInfo: cardInfo, isNewCard: false)) { + section_linkedCards_item(cardInfo) + } + } + + } header: { + Text("Linked Cards") + } + } + + @ViewBuilder + func section_linkedCards_item(_ cardInfo: BoltCardInfo) -> some View { + + HStack(alignment: VerticalAlignment.top, spacing: 0) { + + Group { + if cardInfo.isForeign { + Image(systemName: "key.radiowaves.forward.fill") + .resizable() + .foregroundStyle(Color.white) + } else { + Image("boltcard") + .resizable() + } + } + .scaledToFit() + .aspectRatio(contentMode: .fit) + .frame(width: 42, height: 42, alignment: .center) + .padding(.all, 8) + .background(Color.black.cornerRadius(8)) + .padding(.trailing, 10) + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + Text(cardInfo.sanitizedName) + .lineLimit(1) + .truncationMode(.tail) + .font(.title2) + + Group { + if cardInfo.isFrozen { + Text("Status: Frozen") + } else { + Text("Status: Active") + } + } + .foregroundStyle(.secondary) + } + } + .listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)) + } + + // -------------------------------------------------- + // MARK: Section: Archived Cards + // -------------------------------------------------- + + @ViewBuilder + func section_archivedCards() -> some View { + + Section { + if !archivedCardsHidden { + ForEach(archivedCards) { cardInfo in + navLink(.ManageBoltCard(cardInfo: cardInfo, isNewCard: false)) { + Text(cardInfo.sanitizedName) + } + } + } + + } header: { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Archived Cards") + Spacer() + Button { + withAnimation { + archivedCardsHidden.toggle() + } + } label: { + if archivedCardsHidden { + Image(systemName: "eye") + } else { + Image(systemName: "eye.slash") + } + } + .foregroundColor(.secondary) + } + } + } + + // -------------------------------------------------- + // MARK: Section: New Card + // -------------------------------------------------- + + @ViewBuilder + func section_newCard() -> some View { + + Section { + VStack(alignment: HorizontalAlignment.center, spacing: 10) { + + #if targetEnvironment(simulator) + Button { + showCardOptions() + } label: { + Text("Create New Debit Card") + .font(.title3.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .disabled(nfcUnavailable || isFetchingLnurlwAddr) + #else + #if DEBUG + Button {/* using simultaneousGesture below */} label: { + Text("Create New Debit Card") + .font(.title3.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .disabled(nfcUnavailable || isFetchingLnurlwAddr) + .simultaneousGesture(TapGesture().onEnded { _ in + log.debug("simultaneousGesture: TapGesture") + createNewDebitCard() + }) + .simultaneousGesture(LongPressGesture(minimumDuration: 2.0).onEnded { _ in + log.debug("simultaneousGesture: LongPressGesture") + showCardOptions() + }) + #else + Button { + createNewDebitCard() + } label: { + Text("Create New Debit Card") + .font(.title3.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .disabled(nfcUnavailable || isFetchingLnurlwAddr) + #endif + #endif + + if nfcUnavailable { + Text("NFC capabilities not available on this device.") + .multilineTextAlignment(.center) + .foregroundStyle(Color.appNegative) + } else if lnurlwAddrFetchError { + Text("Error fetching registration. Please check internet connection.") + .multilineTextAlignment(.center) + .foregroundStyle(Color.appNegative) + } else if isFetchingLnurlwAddr { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appAccent)) + Text("Preparing system for NFC...") + } + } + + } // + .frame(maxWidth: .infinity) + + } // + .listRowBackground(Color.clear) + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func navLinkTagBinding() -> Binding { + + return Binding( + get: { navLinkTag != nil }, + set: { if !$0 { navLinkTag = nil }} + ) + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func onAppear() { + log.trace(#function) + + if !didAppear { + didAppear = true + + // First time displaying this View + + #if targetEnvironment(simulator) + // We know the simulator doesn't have NFC capabilities. + // But we have a workaround to support linking a card to a simulator. + // Which is quite helpful for testing. + #else + if !NFCReaderSession.readingAvailable { + nfcUnavailable = true + } + #endif + + } else { + // We are returning to this View + } + } + + func cardsListChanged(_ updatedList: [BoltCardInfo]) { + log.trace(#function) + + sortedCards = updatedList.filter { !$0.isArchived }.sorted { cardA, cardB in + // return true if `cardA` should be ordered before `cardB`; otherwise return false + return (cardA.createdAtDate < cardB.createdAtDate) + } + + archivedCards = updatedList.filter { $0.isArchived }.sorted { cardA, cardB in + // return true if `cardA` should be ordered before `cardB`; otherwise return false + return (cardA.createdAtDate < cardB.createdAtDate) + } + } + // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- + func navigateTo(_ tag: NavLinkTag) { + log.trace("navigateTo(\(tag.description))") + + if #available(iOS 17, *) { + navCoordinator.path.append(tag) + } else { + navLinkTag = tag + } + } + func claimButtonTapped() { - log.trace("claimButtonTapped") + log.trace(#function) let channels = Biz.business.peerManager.channelsValue() guard !channels.isEmpty else { @@ -191,12 +593,12 @@ struct Experimental: View { guard let peer = Biz.business.peerManager.peerStateValue(), - !isClaiming + !isClaimingAddress else { return } - isClaiming = true + isClaimingAddress = true claimError = nil let idx = claimIndex @@ -206,7 +608,7 @@ struct Experimental: View { return } self.claimIndex += 1 - self.isClaiming = false + self.isClaimingAddress = false switch result { case .success(let addr): @@ -236,7 +638,7 @@ struct Experimental: View { } func copyText(_ text: String) -> Void { - log.trace("copyText()") + log.trace(#function) UIPasteboard.general.string = text toast.pop( @@ -245,4 +647,227 @@ struct Experimental: View { style: .chrome ) } + + func showCardOptions() { + log.trace(#function) + + smartModalState.display(dismissable: true) { + CardOptionsSheet( + didSelectVersion: didSelectVersion, + didSelectSimulator: didSelectSimulator + ) + } + } + + func didSelectVersion(_ version: BoltCardVersion) { + log.trace("didSelectVersion: \(version)") + + var missingLnAddress = false + switch version { + case .V1: + fetchLnurlWithdrawAddress(lnAddress: nil) + + case .V1AndV2: + if let lnAddress = Keychain.current.getBip353Address() { + fetchLnurlWithdrawAddress(lnAddress: lnAddress) + } else { + missingLnAddress = true + } + + case .V2: + if let lnAddress = Keychain.current.getBip353Address() { + let input = BoltCardInput.V2(lnAddress: lnAddress) + #if targetEnvironment(simulator) + presentSimulatorPasteSheet(input) + #else + writeToNfcCard(input) + #endif + } else { + missingLnAddress = true + } + } + + if missingLnAddress { + smartModalState.display(dismissable: true) { + PrerequisitesSheet() + } + } + } + + func didSelectSimulator() { + log.trace(#function) + + #if targetEnvironment(simulator) + log.warning("didSelectSimulator(): ignorning - we are in the simulator") + return + #else + smartModalState.display(dismissable: true) { + SimulatorWriteSheet() + } + #endif + } + + func createNewDebitCard() { + log.trace(#function) + + if let lnAddress = Keychain.current.getBip353Address() { + let input = BoltCardInput.V2(lnAddress: lnAddress) + writeToNfcCard(input) + + } else { + smartModalState.display(dismissable: true) { + PrerequisitesSheet() + } + } + } + + // -------------------------------------------------- + // MARK: Create Card + // -------------------------------------------------- + + func fetchLnurlWithdrawAddress(lnAddress: String?) { + log.trace(#function) + + // Developer Note: + // This registration process will **NOT** be needed after we develop the new protocol. + + let continueToNextStep = {(registration: LnurlWithdrawRegistration?) in + + if let hexAddr = registration?.hexAddr { + let input: BoltCardInput + if let lnAddress { + input = BoltCardInput.V1AndV2(lnurlWithdrawId: hexAddr, lnAddress: lnAddress) + } else { + input = BoltCardInput.V1(lnurlWithdrawId: hexAddr) + } + + #if targetEnvironment(simulator) + presentSimulatorPasteSheet(input) + #else + writeToNfcCard(input) + #endif + } else { + lnurlwAddrFetchError = true + } + } + + if let existingRegistration = LnurlwRegistration.existingRegistration() { + continueToNextStep(existingRegistration) + } else { + isFetchingLnurlwAddr = true + lnurlwAddrFetchError = false + + Task { @MainActor in + let registration = await LnurlwRegistration.fetchRegistration() + isFetchingLnurlwAddr = false + continueToNextStep(registration) + } + } + } + + func presentSimulatorPasteSheet(_ input: BoltCardInput) { + log.trace("presentSimulatorPasteSheet()") + + smartModalState.display(dismissable: true) { + SimulatorPasteSheet(input: input) + } + } + + func writeToNfcCard(_ cardInput: BoltCardInput) { + log.trace("writeToNfcCard()") + + let template = cardInput.toTemplate() + + log.debug("template.value: \(template.valueString)") + log.debug("template.piccDataOffset: \(template.piccDataOffset)") + log.debug("template.cmacOffset: \(template.cmacOffset)") + + let keys = BoltCardKeySet.companion.random() + let nfcInput = NfcWriter.WriteInput( + template : template, + key0 : keys.key0_bytes, + piccDataKey : keys.piccDataKey_bytes, + cmacKey : keys.cmacKey_bytes + ) + + NfcWriter.shared.writeCard(nfcInput) { (result: Result) in + + switch result { + case .failure(let error): + log.debug("error: \(error)") + showWriteErrorSheet(error) + + case .success(let output): + log.debug("output.chipUid: \(output.chipUid.toHex())") + saveNewCard(keys, output) + } + } + } + + func saveNewCard( + _ keys: BoltCardKeySet, + _ output: NfcWriter.WriteOutput + ) { + + // Conversion madness: [UInt8] -> Data -> ByteArray -> ByteVector + let uid: Bitcoin_kmpByteVector = output.chipUid.toData().toKotlinByteVector() + + let cardInfo = BoltCardInfo(name: "", keys: keys, uid: uid, isForeign: false) + + Task { @MainActor in + do { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + try await cardsDb.saveCard(card: cardInfo) + navigateTo(.ManageBoltCard(cardInfo: cardInfo, isNewCard: true)) + + } catch { + log.error("SqliteCardsDb.saveCard(): error: \(error)") + } + } + } + + func showWriteErrorSheet(_ error: NfcWriter.WriteError) { + log.trace(#function) + + var shouldIgnoreError = false + if case .scanningTerminated(let nfcError) = error { + shouldIgnoreError = nfcError.isIgnorable() + } + + guard !shouldIgnoreError else { + log.debug("showWriteErrorSheet(): ignoring standard user error") + return + } + + smartModalState.display(dismissable: true) { + WriteErrorSheet(error: error, context: .whileWriting) + } + } + + // -------------------------------------------------- + // MARK: Read Card + // -------------------------------------------------- + + func readCard() { + log.trace(#function) + + NfcReader.shared.readCard { (result: Result) in + + var shouldIgnoreError = false + if case let .failure(error) = result { + if case let .scanningTerminated(nfcError) = error { + shouldIgnoreError = nfcError.isIgnorable() + } + } + + guard !shouldIgnoreError else { + log.debug("NfcReader.readCard(): ignoring standard user error") + return + } + + smartModalState.display(dismissable: true) { + ReadCardSheet(result: result) + } + } + } } diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ArchiveCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ArchiveCardSheet.swift new file mode 100644 index 000000000..0b577cdfc --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ArchiveCardSheet.swift @@ -0,0 +1,129 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ArchiveCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ArchiveCardSheet: View { + + let card: BoltCardInfo + let didArchive: (BoltCardInfo) -> Void + + @State var isUpdatingCard: Bool = false + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Archive card") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 16) { + + Text( + """ + Once a card is archived it can never be activated again. \ + The card will remain in your list, but will be moved to the Archived section. + """ + ) + + Text( + """ + Use this option if your card is permanantly lost or stolen. + """ + ) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + if isUpdatingCard { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appAccent)) + } + Spacer() + + Button { + cancelButtonTapped() + } label: { + Text("Cancel").font(.title3) + } + .disabled(isUpdatingCard) + .padding(.trailing, 24) + + Button { + archiveButtonTapped() + } label: { + Text("Archive").font(.title3).foregroundStyle(Color.red) + } + .disabled(isUpdatingCard) + } + .padding(.top, 16) // extra padding + } + .padding(.top, 16) + .padding(.horizontal) + } + + func cancelButtonTapped() { + log.trace(#function) + + smartModalState.close() + } + + func archiveButtonTapped() { + log.trace(#function) + + isUpdatingCard = true + Task { @MainActor in + + var result: BoltCardInfo? = nil + do { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + + // Try to get the most recent version of the card, + // just in-case any changes were made elsewhere in the system. + // + let currentCard = cardsDb.cardForId(cardId: card.id) ?? card + let updatedCard = currentCard.archivedCopy() + + try await cardsDb.saveCard(card: updatedCard) + result = updatedCard + + } catch { + log.error("SqliteCardsDb.saveCard(): error: \(error)") + } + + self.isUpdatingCard = false + self.smartModalState.close(animationCompletion: { + if let result { + self.didArchive(result) + } + }) + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardInput.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardInput.swift new file mode 100644 index 000000000..8d300d9d6 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardInput.swift @@ -0,0 +1,88 @@ +import Foundation +import DnaCommunicator + +/// Versioning information +enum BoltCardVersion { + + /// Version 1 was the original version. + /// The card contains a lnurl-withdraw address, along with the values dynamically generated by the card. + /// + /// E.g.: https://phoenix.app/lnurlw?id=abc123&picc_data=X&cmac=Y + case V1 + + /// Version 1 & 2 combines the lnurl-withdraw address along with a `v2` queryParameter, + /// for those services that support both versions. + /// + /// E.g.: https://phoenix.app/lnurlw?id=abc123&v2=alice@phoenixwallet.me&picc_data=X&cmac=Y + case V1AndV2 + + /// Version 2 is the new lightning-only version. + /// (Add link to documentation here...) + /// + /// E.g.: ₿alice@phoenixwallet.me?picc_data=X&cmac=Y + /// + case V2 +} + +/// Required input to create a new NFC card. +/// Multiple versions are supported (at least for testing/debug purposes). +/// +enum BoltCardInput: Codable { + case V1(lnurlWithdrawId: String) + case V1AndV2(lnurlWithdrawId: String, lnAddress: String) + case V2(lnAddress: String) + + var version: BoltCardVersion { + switch self { + case .V1: + return .V1 + case .V1AndV2: + return .V1AndV2 + case .V2: + return .V2 + } + } + + func toTemplate() -> Ndef.Template { + + let lnurlWithdrawBaseUrl = URL(string: "https://phoenix.deusty.com/v1/pub/lnurlw/info")! + var queryItems: [URLQueryItem] = [] + + switch self { + case .V1(let lnurlWithdrawId): + queryItems.append(URLQueryItem(name: "id", value: lnurlWithdrawId)) + + var comps = URLComponents(url: lnurlWithdrawBaseUrl, resolvingAgainstBaseURL: false)! + comps.queryItems = queryItems + let resolvedUrl = comps.url! + + return Ndef.Template(baseUrl: resolvedUrl)! + + case .V1AndV2(let lnurlWithdrawId, let lnAddress): + let sanitizedLnAddress = lnAddress.starts(with: "₿") ? lnAddress : "₿\(lnAddress)" + + queryItems.append(URLQueryItem(name: "id", value: lnurlWithdrawId)) + queryItems.append(URLQueryItem(name: "v2", value: sanitizedLnAddress)) + + var comps = URLComponents(url: lnurlWithdrawBaseUrl, resolvingAgainstBaseURL: false)! + comps.queryItems = queryItems + let resolvedUrl = comps.url! + + return Ndef.Template(baseUrl: resolvedUrl)! + + case .V2(let lnAddress): + let sanitizedLnAddress = lnAddress.starts(with: "₿") ? lnAddress : "₿\(lnAddress)" + + return Ndef.Template(baseText: sanitizedLnAddress) + } + } +} + +/// The iOS simulator doesn't support NFC. +/// So the simulator has no way to write to an NFC card. +/// But you can link a card to a simulator wallet for testing. +/// +struct SimulatorBoltCardInput: Codable { + let key0: String + let chipUid: String +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardsHelp.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardsHelp.swift new file mode 100644 index 000000000..ff619bf4c --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/BoltCardsHelp.swift @@ -0,0 +1,141 @@ +import SwiftUI + +fileprivate let filename = "BoltCardsHelp" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct BoltCardsHelp: View { + + @Binding var isShowing: Bool + + @ViewBuilder + var body: some View { + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + header() + content() + Spacer() + } + } + + @ViewBuilder + func header() -> some View { + + // close button + // (required for landscapse mode, where swipe-to-dismiss isn't possible) + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + Button { + close() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + } // + .padding() + } + + @ViewBuilder + func content() -> some View { + + ScrollView(.vertical) { + VStack(alignment: HorizontalAlignment.leading, spacing: 32) { + content_intro() + content_whereToBuy() + content_howDoesItWork() + } // + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal) + .padding(.horizontal) + .padding(.bottom) + } + } + + @ViewBuilder + func content_intro() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 24) { + + Text("Bolt Card") + .font(.title) + + Text("Bitcoin payments over the lightning network with a contactless payment card.") + + Text( + """ + You can link multiple debit cards to your wallet. \ + Set custom spending limits per card, and freeze a card at anytime. + """ + ) + + } // + } + + @ViewBuilder + func content_whereToBuy() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 8) { + + Text("Where can I buy bolt cards ?") + .font(.headline) + + Text( + """ + What you need are blank NFC "NTAG 424 DNA" cards. \ + You can buy them from many different vendors. + """ + ) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) // text truncation bugs + .padding(.bottom, 8) + + Text(" • [CoinCorner.com](https://www.coincorner.com/BuyTheBoltCard)") + Text(" • [Laser Eyes Cards](https://lasereyes.cards/)") + Text(" • [PlebTag](https://plebtag.com/)") + Text(" • [Yanabu Bolt Card - Korea](https://marpple.shop/kr/yanabu/products/13356281)") + Text(" • [Bolt Ring](https://bitcoin-ring.com/)") + Text(" • [NFC.cards](https://nfc.cards/en/white-cards/46-nfc-card-ntag424-dna.html)") + Text(" • [ZipNFC.com](https://zipnfc.com/nfc-pvc-card-credit-card-size-ntag424-dna.html)") + Text(" • [Hirsch](https://shop.hirschsecure.com/products/printed-nxp-ntag-424-dna-tag-5-pack)") + } + } + + @ViewBuilder + func content_howDoesItWork() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 24) { + + Text("How does Bolt card work ?") + .font(.headline) + + Text( + """ + The NFC card is programmed with a BLIP XX address and a set of secure keys. \ + The card then produces the address plus two unique hashes that change each \ + time the card is scanned. + """ + ) + Text( + """ + The merchant can use these values to make a one time request to your wallet. \ + After that, the card must be tapped again to get fresh values. + """ + ) + Text( + """ + Your wallet verifies the card is not frozen, and checks the payment amount \ + against any daily/monthly spending limits you may have configured. + """ + ) + } // + } + + func close() { + log.trace(#function) + + isShowing = false + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/CardOptionsSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/CardOptionsSheet.swift new file mode 100644 index 000000000..15aaacbb9 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/CardOptionsSheet.swift @@ -0,0 +1,208 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "CardOptionsSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct CardOptionsSheet: View { + + let didSelectVersion: (BoltCardVersion) -> Void + let didSelectSimulator: () -> Void + + @EnvironmentObject var smartModalState: SmartModalState + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Card options") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + button_v1() + button_v1_and_v2() + button_v2() + #if !targetEnvironment(simulator) + button_simulator() + #endif + } // + .frame(maxWidth: .infinity) + .padding(.all) + } + + @ViewBuilder + func button_v1() -> some View { + + Button { + didTapVersion(BoltCardVersion.V1) + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text("Version 1 (lnurl-withdraw)") + .font(.body) + + Text(verbatim: "https://phoenix.app/lnurlw?id=abc123&picc_data=X&cmac=Y") + .font(.footnote) + .lineLimit(2) + .truncationMode(.tail) + .foregroundColor(.secondary) + } // + .multilineTextAlignment(.leading) + + Spacer() + } // + .padding(.all, 8) + .contentShape(Rectangle()) // make Spacer area tappable + } + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle(radius: 16)) + } + + @ViewBuilder + func button_v1_and_v2() -> some View { + + Button { + didTapVersion(BoltCardVersion.V1AndV2) + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text("Version 1 & 2 (lnurl-withdraw + v2 param)") + + Text(verbatim: "https://phoenix.app/lnurlw?id=abc123&v2=alice@phoenixwallet.me&picc_data=X&cmac=Y") + .font(.footnote) + .lineLimit(2) + .truncationMode(.tail) + .foregroundColor(.secondary) + } // + .multilineTextAlignment(.leading) + + Spacer() + } // + .padding(.all, 8) + .contentShape(Rectangle()) // make Spacer area tappable + } + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle(radius: 16)) + } + + @ViewBuilder + func button_v2() -> some View { + + Button { + didTapVersion(BoltCardVersion.V2) + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text("Version 2 (bip-353 & onion messages)") + .font(.body) + + Text(verbatim: "₿alice@phoenixwallet.me?picc_data=X&cmac=Y") + .font(.footnote) + .lineLimit(2) + .truncationMode(.tail) + .foregroundColor(.secondary) + } // + .multilineTextAlignment(.leading) + + Spacer() + } // + .padding(.all, 8) + .contentShape(Rectangle()) // make Spacer area tappable + } + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle(radius: 16)) + } + + @ViewBuilder + func button_simulator() -> some View { + + Button { + didTapSimulator() + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text("Write card for simulator") + .font(.body) + + Text(verbatim: "Copy/paste info from simulator") + .font(.footnote) + .lineLimit(2) + .truncationMode(.tail) + .foregroundColor(.secondary) + } // + .multilineTextAlignment(.leading) + + Spacer() + } // + .padding(.all, 8) + .contentShape(Rectangle()) // make Spacer area tappable + } + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle(radius: 16)) + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func didTapVersion(_ version: BoltCardVersion) { + log.trace("didTapVersion(\(version))") + + smartModalState.close(animationCompletion: { + didSelectVersion(version) + }) + } + + func didTapSimulator() { + log.trace(#function) + + smartModalState.close(animationCompletion: { + didSelectSimulator() + }) + } + + func closeButtonTapped() { + log.trace(#function) + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/DeleteCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/DeleteCardSheet.swift new file mode 100644 index 000000000..6fe527d6b --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/DeleteCardSheet.swift @@ -0,0 +1,125 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "DeleteCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct DeleteCardSheet: View { + + let card: BoltCardInfo + let didDelete: () -> Void + + @State var isUpdatingCard: Bool = false + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Delete card") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 16) { + + Text( + """ + Are you sure you want to delete this card? + """ + ) + + Text( + """ + Any payments made with this card will remain in your transaction history, \ + but will no longer be linked with any card. + """ + ) + + if !card.isReset { + Text( + """ + If you still have access to the physical card, \ + it's recommended that you **reset** the card first. \ + This will allow it to be linked again with any wallet. + """ + ) + } + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + if isUpdatingCard { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appAccent)) + } + Spacer() + + Button { + cancelButtonTapped() + } label: { + Text("Cancel").font(.title3) + } + .padding(.trailing, 24) + + Button { + deleteButtonTapped() + } label: { + Text("Delete").font(.title3).foregroundStyle(Color.red) + } + } + .padding(.top, 16) // extra padding + } + .padding(.top, 16) + .padding(.horizontal) + } + + func cancelButtonTapped() { + log.trace(#function) + + smartModalState.close() + } + + func deleteButtonTapped() { + log.trace(#function) + + isUpdatingCard = true + Task { @MainActor in + do { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + try await cardsDb.deleteCard(cardId: card.id) + } catch { + log.error("SqliteCardsDb.updateCard(): error: \(error)") + } + + self.isUpdatingCard = false + self.smartModalState.close(animationCompletion: { + self.didDelete() + }) + } + } +} + diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/LnurlwRegistration.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/LnurlwRegistration.swift new file mode 100644 index 000000000..d63a639b8 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/LnurlwRegistration.swift @@ -0,0 +1,112 @@ +import Foundation + +fileprivate let filename = "LnurlwRegistration" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +/// Developer Note: +/// This registration process will **NOT** be needed after we develop the new protocol. +/// +class LnurlwRegistration { + + struct LnurlWithdrawRegisterResponse: Decodable { + let node_id: String + let hex_addr: String + } + + static func existingRegistration() -> LnurlWithdrawRegistration? { + log.trace(#function) + + guard let nodeIdHash: String = Biz.walletInfo?.nodeIdHash else { + return nil + } + + if let prvRegistration = Prefs.current.lnurlWithdrawRegistration { + if prvRegistration.nodeIdHash == nodeIdHash { + // We've already registered. + log.debug("LnurlWithdraw: already registered") + return prvRegistration + } + } + + return nil + } + + static func fetchRegistration() async -> LnurlWithdrawRegistration? { + log.trace("fetchRegistration()") + + // **Developer Note**: + // This registration process will NOT be needed after we develop the new protocol. + + guard let walletInfo = Biz.walletInfo else { + return nil + } + + let nodeId: String = walletInfo.nodeIdString + let nodeIdHash: String = walletInfo.nodeIdHash + + let url = URL(string: "https://phoenix.deusty.com/v1/pub/lnurlw/me") + guard let requestUrl = url else { return nil } + + let body = [ + "node_id": nodeId + ] + let bodyData = try? JSONSerialization.data( + withJSONObject: body, + options: [] + ) + + var request = URLRequest(url: requestUrl) + request.httpMethod = "POST" + request.httpBody = bodyData + + var registration: LnurlWithdrawRegistration? = nil + do { + log.debug("/lnurlw/me: sending...") + let (data, response) = try await URLSession.shared.data(for: request) + + var statusCode = 418 + var success = false + if let httpResponse = response as? HTTPURLResponse { + statusCode = httpResponse.statusCode + if statusCode >= 200 && statusCode < 300 { + success = true + } + } + + if success { + log.debug("/lnurlw/me: success") + + let response: LnurlWithdrawRegisterResponse + do { + response = try JSONDecoder().decode(LnurlWithdrawRegisterResponse.self, from: data) + log.debug("/lnurlw/me: hex_addr: \(response.hex_addr)") + + // Store the value in Prefs so we can skip this step in the future + registration = LnurlWithdrawRegistration( + hexAddr: response.hex_addr, + nodeIdHash: nodeIdHash, + registrationDate: Date.now + ) + Prefs.current.lnurlWithdrawRegistration = registration + + } catch { + log.debug("/lnurlw/me: JSON decoding error: \(error)") + } + } else { + log.debug("/lnurlw/me: statusCode: \(statusCode)") + if let dataString = String(data: data, encoding: .utf8) { + log.debug("/lnurlw/me: response:\n\(dataString)") + } + } + + } catch { + log.debug("/lnurlw/me: error: \(error)") + } + + return registration + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ManageBoltCard.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ManageBoltCard.swift new file mode 100644 index 000000000..1b7b4b05d --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ManageBoltCard.swift @@ -0,0 +1,1341 @@ +import SwiftUI +import PhoenixShared +import Combine + +fileprivate let filename = "ManageBoltCard" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ManageBoltCard: View { + + enum NavLinkTag: Hashable, CustomStringConvertible { + + case CurrencyConverter( + initialAmount : CurrencyAmount?, + didChange : ((CurrencyAmount?) -> Void)?, + didClose : (() -> Void)? + ) + + private var internalValue: Int { + switch self { + case .CurrencyConverter(_, _, _): return 1 + } + } + + static func == (lhs: NavLinkTag, rhs: NavLinkTag) -> Bool { + return lhs.internalValue == rhs.internalValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.internalValue) + } + + var description: String { + switch self { + case .CurrencyConverter: return "CurrencyConverter" + } + } + } + + struct SpendingLimitGraphInfo { + let spent: String + let spentAmount: Double + + let remaining: String + let remainingAmount: Double + + let total: String + let totalAmount: Double + } + + @State var cardInfo: BoltCardInfo + let isNewCard: Bool + + @State var name: String = "" + @State var isActive: Bool = true + + @State var currencyList: [Currency] = [Currency.bitcoin(.sat)] + + @State var dailyLimit_currencyStr: String = Currency.bitcoin(.sat).shortName + @State var dailyLimit_currency: Currency = Currency.bitcoin(.sat) + @State var dailyLimit_amountStr: String = "" + @State var dailyLimit_parsedAmount: Result = .failure(.emptyInput) + + @State var monthlyLimit_currencyStr: String = Currency.bitcoin(.sat).shortName + @State var monthlyLimit_currency: Currency = Currency.bitcoin(.sat) + @State var monthlyLimit_amountStr: String = "" + @State var monthlyLimit_parsedAmount: Result = .failure(.emptyInput) + + @State var cardAmounts: SqliteCardsDb.CardAmounts? = nil + + @State var dailyCardPaymentsAmount: Double = 0 + @State var monthlyCardPaymentsAmount: Double = 0 + + @State var isSaving: Bool = false + @State var showDiscardChangesConfirmationDialog: Bool = false + @State var showDeleteContactConfirmationDialog: Bool = false + + @State var didAppear: Bool = false + @State var didDisplayWelcome: Bool = false + + @State var ignoreChanges: Bool = true + @State var isFirstUserEdit: Bool = true + + @State var popoverPresent_dailyLimit: Bool = false + @State var popoverPresent_monthlyLimit: Bool = false + + @State var cardWasArchived: Bool = false + @State var cardWasReset: Bool = false + + // + @State var navLinkTag: NavLinkTag? = nil + // + + @StateObject var toast = Toast() + + @ObservedObject var currencyPrefs = CurrencyPrefs.current + + @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.presentationMode) var presentationMode: Binding + + @EnvironmentObject var smartModalState: SmartModalState + @EnvironmentObject var navCoordinator: NavigationCoordinator + + let didBecomeActivePublisher = NotificationCenter.default.publisher( + for: UIApplication.didBecomeActiveNotification + ) + + init(cardInfo: BoltCardInfo, isNewCard: Bool) { + self.cardInfo = cardInfo + self.isNewCard = isNewCard + } + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + layers() + .navigationTitle("Manage Card") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { toolbarItems() } + .navigationStackDestination(isPresented: navLinkTagBinding()) { // iOS 16 + navLinkView() + } + .navigationStackDestination(for: NavLinkTag.self) { tag in // iOS 17+ + navLinkView(tag) + } + .task { + await fetchCardAmounts() + } + } + + @ToolbarContentBuilder + func toolbarItems() -> some ToolbarContent { + + if isNewCard || cardWasArchived || cardWasReset { + ToolbarItem(placement: .navigationBarLeading) { + Button { + saveButtonTapped() + } label: { + Text("Done").font(.headline) + } + .disabled(!canSave || isSaving) // subtle difference here + .accessibilityLabel("Save changes") + } + } else { + ToolbarItem(placement: .navigationBarLeading) { + Button { + cancelButtonTapped() + } label: { + Text("Cancel").font(.headline) + } + .disabled(isSaving) + .accessibilityLabel("Discard changes") + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + saveButtonTapped() + } label: { + Text("Done").font(.headline) + } + .disabled(!hasChanges || !canSave || isSaving) + .accessibilityLabel("Save changes") + } + } + } + + @ViewBuilder + func layers() -> some View { + + ZStack { + content() + toast.view() + } + } + + @ViewBuilder + func content() -> some View { + + List { + section_name() + if !cardInfo.isForeign { + section_status() + } + if !cardInfo.isForeign && !cardInfo.isArchived { + section_limits() + } + section_managementTasks() + } + .listStyle(.insetGrouped) + .listBackgroundColor(.primaryBackground) + .onAppear { + onAppear() + } + .onChange(of: cardInfo) { _ in + cardInfoChanged() + } + .onChange(of: isActive) { _ in + isActiveChanged() + } + .onChange(of: dailyLimit_currencyStr) { _ in + dailyLimit_currencyPickerDidChange() + } + .onChange(of: monthlyLimit_currencyStr) { _ in + monthlyLimit_currencyPickerDidChange() + } + .onChange(of: dailyLimit_currency) { _ in + dailyLimit_currencyChanged() + } + .onChange(of: monthlyLimit_currency) { _ in + monthlyLimit_currencyChanged() + } + .onChange(of: cardAmounts) { _ in + cardAmountsChanged() + } + .onReceive(didBecomeActivePublisher) { _ in + applicationDidBecomeActive() + } + .confirmationDialog("Discard changes?", + isPresented: $showDiscardChangesConfirmationDialog, + titleVisibility: Visibility.hidden + ) { + Button("Discard changes", role: ButtonRole.destructive) { + close() + } + } + } + + @ViewBuilder + func section_name() -> some View { + + Section { + HStack(alignment: VerticalAlignment.center, spacing: 0) { + TextField(BoltCardInfo.defaultName, text: $name) + + // Clear button (appears when TextField's text is non-empty) + Button { + name = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(Color(UIColor.tertiaryLabel)) + } + .isHidden(name.isEmpty) + } + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + + } header: { + Text("Name") + } + } + + @ViewBuilder + func section_status() -> some View { + + Section { + + HStack(alignment: VerticalAlignment.centerTopLine) { + + Group { + if isActive { + Text("Active") + } else if cardInfo.isArchived { + Text("Frozen (archived)", comment: "translate: archived") + } else { + Text("Frozen") + } + } + .font(.title3.weight(.medium)) + + Spacer() + + Toggle("", isOn: $isActive) + .labelsHidden() + .disabled(cardInfo.isArchived) + .padding(.trailing, 2) + + } // + + Group { + if isActive { + Text("An active card can be used for payments.") + } else { + Text("All payment attempts will be rejected.") + } + } + .font(.callout) + .fixedSize(horizontal: false, vertical: true) // SwiftUI truncation bugs + .foregroundColor(Color.secondary) + .padding(.top, 8) + .padding(.bottom, 4) + + } header: { + Text("Status") + } + } + + @ViewBuilder + func section_limits() -> some View { + + Section { + + section_limits_daily() + .padding(.top, 4) + .padding(.bottom, 8) + + section_limits_monthly() + .padding(.top, 8) + .padding(.bottom, 4) + + } header: { + Text("Spending Limits") + } + } + + @ViewBuilder + func section_limits_daily() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + Text("**Daily** spending limit:") + Spacer(minLength: 0) + Button { + popoverPresent_dailyLimit = true + } label: { + Image(systemName: "info.circle") + } + .buttonStyle(BorderlessButtonStyle()) // prevents trigger when row tapped + .foregroundColor(.secondary) + .popover(present: $popoverPresent_dailyLimit) { + InfoPopoverWindow { + Text("Limit applies from midnight to midnight (local time).") + } + } + + } // + .padding(.bottom, 8) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + TextField( + "None", + text: dailyLimit_currencyStyler().amountProxy + ) + .keyboardType(.decimalPad) + .disableAutocorrection(true) + .disabled(isSaving) + .foregroundColor(dailyLimit_parsedAmount.isError ? Color.appNegative : Color.primaryForeground) + + Picker( + selection: $dailyLimit_currencyStr, + label: Text("") + ) { + ForEach(currencyPickerOptions(), id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(MenuPickerStyle()) + .disabled(isSaving) + .accessibilityLabel("") // see below + .accessibilityHint("Currency picker") + + // For a Picker, iOS is setting the VoiceOver text twice: + // > "sat sat, Button" + // + // If we change the accessibilityLabel to "foobar", then we get: + // > "sat foobar, Button" + // + // So we have to set it to the empty string to avoid the double-word. + + } // + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + .padding(.bottom, 16) + + section_limits_graph(dailyLimit_graphInfo()) + .padding(.bottom, 8) + + } // + } + + @ViewBuilder + func section_limits_monthly() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + Text("**Monthly** spending limit:") + Spacer(minLength: 0) + Button { + popoverPresent_monthlyLimit = true + } label: { + Image(systemName: "info.circle") + } + .buttonStyle(BorderlessButtonStyle()) // prevents trigger when row tapped + .foregroundColor(.secondary) + .popover(present: $popoverPresent_monthlyLimit) { + InfoPopoverWindow { + Text("Limit applies from 1st of the month at midnight to the following 1st (local time).") + } + } + } // + .padding(.bottom, 8) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + + TextField( + "None", + text: monthlyLimit_currencyStyler().amountProxy + ) + .keyboardType(.decimalPad) + .disableAutocorrection(true) + .disabled(isSaving) + .foregroundColor(monthlyLimit_parsedAmount.isError ? Color.appNegative : Color.primaryForeground) + + Picker( + selection: $monthlyLimit_currencyStr, + label: Text("") + ) { + ForEach(currencyPickerOptions(), id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(MenuPickerStyle()) + .disabled(isSaving) + .accessibilityLabel("") // see below + .accessibilityHint("Currency picker") + + // For a Picker, iOS is setting the VoiceOver text twice: + // > "sat sat, Button" + // + // If we change the accessibilityLabel to "foobar", then we get: + // > "sat foobar, Button" + // + // So we have to set it to the empty string to avoid the double-word. + + } // + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + .padding(.bottom, 16) + + section_limits_graph(monthlyLimit_graphInfo()) + .padding(.bottom, 8) + + } // + } + + @ViewBuilder + func section_limits_graph(_ graphInfo: SpendingLimitGraphInfo) -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 4) { + + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Image(systemName: "square.fill") + .font(.subheadline) + .imageScale(.small) + .foregroundColor(spentBalanceColor) + Text("Spent") + Spacer(minLength: 0) + Text("Remaining") + Image(systemName: "square.fill") + .imageScale(.small) + .font(.subheadline) + .foregroundColor(remainingBalanceColor) + } + + ProgressView(value: graphInfo.spentAmount, total: graphInfo.totalAmount) + .tint(spentBalanceColor) + .background(remainingBalanceColor) + .padding(.vertical, 4) + + HStack(alignment: VerticalAlignment.center, spacing: 2) { + Text(graphInfo.spent) + Spacer(minLength: 0) + Text(graphInfo.remaining) + } + .font(.callout) + .foregroundColor(.primary.opacity(0.8)) + + } // + } + + @ViewBuilder + func section_managementTasks() -> some View { + + Section { + + if !cardInfo.isArchived && !cardInfo.isForeign { + Button { + archiveCard() + } label: { + Text("Archive card…") + } + } + if !cardInfo.isReset { + Button { + resetPhysicalCard() + } label: { + Text("Reset physical card…") + } + } + Button("Delete card…", role: ButtonRole.destructive) { + deleteCard() + } + + } header: { + Text("Management Tasks") + } + } + + @ViewBuilder + func currencyText(_ option: CurrencyPickerOption) -> some View { + + // From what I can tell, Apple won't let us do any formatting here. + // Things I've tried that don't work: + // + // #1 + // ``` + // HStack {Text("A") Text("B")} + // ``` + // ^ You just get "A" + // + // #2 + // ``` + // Text("A") + Text("B").fontWeight(.thin) + // ``` + // ^ You just get "AB" without the formatting + + switch option { + case .currency(let currency): + Text(currency.shortName) + case .other: + Text(option.description) + } + } + + @ViewBuilder + func navLinkView() -> some View { + + if let tag = self.navLinkTag { + navLinkView(tag) + } else { + EmptyView() + } + } + + @ViewBuilder + func navLinkView(_ tag: NavLinkTag) -> some View { + + switch tag { + case .CurrencyConverter(let initialAmount, let didChange, let didClose): + CurrencyConverterView( + initialAmount: initialAmount, + didChange: didChange, + didClose: didClose + ) + } + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func navLinkTagBinding() -> Binding { + + return Binding( + get: { navLinkTag != nil }, + set: { if !$0 { navLinkTag = nil }} + ) + } + + func dailyLimit_currencyStyler() -> TextFieldCurrencyStyler { + + return TextFieldCurrencyStyler( + currency: dailyLimit_currency, + amount: $dailyLimit_amountStr, + parsedAmount: $dailyLimit_parsedAmount, + hideMsats: false, + userDidEdit: dailyLimit_userDidEdit + ) + } + + func monthlyLimit_currencyStyler() -> TextFieldCurrencyStyler { + + return TextFieldCurrencyStyler( + currency: monthlyLimit_currency, + amount: $monthlyLimit_amountStr, + parsedAmount: $monthlyLimit_parsedAmount, + hideMsats: false, + userDidEdit: monthlyLimit_userDidEdit + ) + } + + func currencyPickerOptions() -> [String] { + + var options = [String]() + for currency in currencyList { + options.append(currency.shortName) + } + + options.append( + String( + localized: "other", + comment: "Option in currency picker list. Sends user to Currency Converter" + ) + ) + + return options + } + + func dailyLimit_isInvalidAmount() -> Bool { + return isInvalidAmount(dailyLimit_parsedAmount) + } + + func monthlyLimit_isInvalidAmount() -> Bool { + return isInvalidAmount(monthlyLimit_parsedAmount) + } + + func isInvalidAmount(_ result: Result) -> Bool { + + switch result { + case .success(let amt): + return amt <= 0 + + case .failure(let reason): + switch reason { + case .emptyInput: + return false + case .invalidInput: + return true + } + } + } + + func dailyLimit_graphInfo() -> SpendingLimitGraphInfo { + return graphInfo(dailyLimit_currency, dailyCardPaymentsAmount, dailyLimit_parsedAmount) + } + + func monthlyLimit_graphInfo() -> SpendingLimitGraphInfo { + return graphInfo(monthlyLimit_currency, monthlyCardPaymentsAmount, monthlyLimit_parsedAmount) + } + + func graphInfo( + _ currency: Currency, + _ paymentsAmount: Double, + _ parsedAmount: Result + ) -> SpendingLimitGraphInfo { + + var spentAmount: Double = 0.0 + var spent = "" + + if cardAmounts != nil { + spentAmount = paymentsAmount + switch currency { + case .bitcoin(let bitcoinUnit): + spent = Utils.formatBitcoin(amount: spentAmount, bitcoinUnit: bitcoinUnit).digits + case .fiat(let fiatCurrency): + spent = Utils.formatFiat(amount: spentAmount, fiatCurrency: fiatCurrency).digits + } + } else { + switch currency { + case .bitcoin(let bitcoinUnit): + spent = Utils.unknownBitcoinAmount(bitcoinUnit: bitcoinUnit).digits + case .fiat(let fiatCurrency): + spent = Utils.unknownFiatAmount(fiatCurrency: fiatCurrency).digits + } + } + + var totalAmount: Double = 0.0 + var total = "" + + var remainingAmount: Double = 0.0 + var remaining = "" + + switch parsedAmount { + case .success(let limit): + totalAmount = limit + remainingAmount = max(0.0, limit - spentAmount) + switch currency { + case .bitcoin(let bitcoinUnit): + total = Utils.formatBitcoin(amount: totalAmount, bitcoinUnit: bitcoinUnit).digits + remaining = Utils.formatBitcoin(amount: remainingAmount, bitcoinUnit: bitcoinUnit).digits + case .fiat(let fiatCurrency): + total = Utils.formatFiat(amount: totalAmount, fiatCurrency: fiatCurrency).digits + remaining = Utils.formatFiat(amount: remainingAmount, fiatCurrency: fiatCurrency).digits + } + + case .failure(_): + totalAmount = Double.greatestFiniteMagnitude + total = "♾️" + remainingAmount = Double.greatestFiniteMagnitude + remaining = "♾️" + } + + return SpendingLimitGraphInfo( + spent: spent, + spentAmount: spentAmount, + remaining: remaining, + remainingAmount: remainingAmount, + total: total, + totalAmount: totalAmount + ) + } + + var spentBalanceColor: Color { + if Biz.isTestnet { + return Color.appAccentTestnet + } else { + return Color.appAccentMainnet + } + } + + var remainingBalanceColor: Color { + return Color.appAccentOrange + } + + var hasChanges: Bool { + + if cardInfo.sanitizedName != name { + return true + } + if cardInfo.isActive != isActive { + return true + } + + if cardInfo.dailyLimit?.toCurrencyAmount() != dailyLimit_currencyAmount() { + return true + } + + if cardInfo.monthlyLimit?.toCurrencyAmount() != monthlyLimit_currencyAmount() { + return true + } + + return false + } + + var canSave: Bool { + + if dailyLimit_isInvalidAmount() { + return false + } + if monthlyLimit_isInvalidAmount() { + return false + } + + return true + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func onAppear() { + log.trace(#function) + + if !didAppear { + didAppear = true + cardInfoChanged() + } + } + + func cardInfoChanged() { + log.trace(#function) + + ignoreChanges = true + + name = cardInfo.sanitizedName + isActive = cardInfo.isActive + + let dsl = cardInfo.dailyLimit?.toCurrencyAmount() + let msl = cardInfo.monthlyLimit?.toCurrencyAmount() + + var plus: [Currency] = [] + if let dsl { + plus.append(dsl.currency) + } + if let msl { + plus.append(msl.currency) + } + currencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: plus) + + if let dsl { + dailyLimit_currencyStr = dsl.currency.shortName + dailyLimit_currency = dsl.currency + + let formattedAmt = Utils.format(currencyAmount: dsl) + dailyLimit_parsedAmount = Result.success(formattedAmt.amount) // do this first ! + dailyLimit_amountStr = formattedAmt.digits + + } else { + dailyLimit_currencyStr = currencyPrefs.currency.shortName + dailyLimit_currency = currencyPrefs.currency + } + + if let msl { + monthlyLimit_currencyStr = msl.currency.shortName + monthlyLimit_currency = msl.currency + + let formattedAmt = Utils.format(currencyAmount: msl) + monthlyLimit_parsedAmount = Result.success(formattedAmt.amount) // do this first ! + monthlyLimit_amountStr = formattedAmt.digits + + } else { + monthlyLimit_currencyStr = currencyPrefs.currency.shortName + monthlyLimit_currency = currencyPrefs.currency + } + + // If the user has only edit one limit, change the currency of the other to match. + if dailyLimit_amountStr.isEmpty && !monthlyLimit_amountStr.isEmpty { + dailyLimit_currencyStr = monthlyLimit_currencyStr + dailyLimit_currency = monthlyLimit_currency + } + if monthlyLimit_amountStr.isEmpty && !dailyLimit_amountStr.isEmpty { + monthlyLimit_currencyStr = dailyLimit_currencyStr + monthlyLimit_currency = dailyLimit_currency + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.ignoreChanges = false + } + } + + func applicationDidBecomeActive() { + log.trace(#function) + + if isNewCard && !didDisplayWelcome { + didDisplayWelcome = true + + smartModalState.display(dismissable: true) { + NewCardSheet() + } + } + } + + func isActiveChanged() { + log.trace(#function) + + maybeShowSaveToast() + } + + func dailyLimit_currencyPickerDidChange() { + log.trace(#function) + + guard !ignoreChanges else { + log.debug("dailyLimit_currencyPickerDidChange(): ignoreChanges") + return + } + + if let newCurrency = currencyList.first(where: { $0.shortName == dailyLimit_currencyStr }) { + if dailyLimit_currency != newCurrency { + dailyLimit_currency = newCurrency + + // We might want to apply a different formatter + let result = TextFieldCurrencyStyler.format( + input : dailyLimit_amountStr, + currency : dailyLimit_currency, + hideMsats : false + ) + dailyLimit_parsedAmount = result.1 + dailyLimit_amountStr = result.0 + + // If the user hasn't edited the other field, change the currency to match. + if monthlyLimit_amountStr.isEmpty { + monthlyLimit_currencyStr = dailyLimit_currencyStr + } + } + + } else { // user selected "other" + + dailyLimit_currencyStr = dailyLimit_currency.shortName // revert to last real currency + navigateTo( + .CurrencyConverter( + initialAmount : dailyLimit_currencyAmount(), + didChange : dailyLimit_currencyConverterAmountChanged, + didClose : nil + ) + ) + } + } + + func monthlyLimit_currencyPickerDidChange() { + log.trace(#function) + + guard !ignoreChanges else { + log.debug("\(#function): ignoreChanges") + return + } + + if let newCurrency = currencyList.first(where: { $0.shortName == dailyLimit_currencyStr }) { + if monthlyLimit_currency != newCurrency { + monthlyLimit_currency = newCurrency + + // We might want to apply a different formatter + let result = TextFieldCurrencyStyler.format( + input : monthlyLimit_amountStr, + currency : monthlyLimit_currency, + hideMsats : false + ) + monthlyLimit_parsedAmount = result.1 + monthlyLimit_amountStr = result.0 + + // If the user hasn't edited the other field, change the currency to match. + if dailyLimit_amountStr.isEmpty { + dailyLimit_currencyStr = monthlyLimit_currencyStr + } + } + + } else { // user selected "other" + + monthlyLimit_currencyStr = monthlyLimit_currency.shortName // revert to last real currency + navigateTo( + .CurrencyConverter( + initialAmount : monthlyLimit_currencyAmount(), + didChange : monthlyLimit_currencyConverterAmountChanged, + didClose : nil + ) + ) + } + } + + func dailyLimit_currencyConverterAmountChanged(_ result: CurrencyAmount?) { + log.trace(#function) + + if let newAmt = result { + + let plus: [Currency] = [newAmt.currency, monthlyLimit_currency] + let newCurrencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: plus) + if currencyList != newCurrencyList { + currencyList = newCurrencyList + } + + dailyLimit_currency = newAmt.currency + dailyLimit_currencyStr = newAmt.currency.shortName + + let formattedAmt = Utils.format(currencyAmount: newAmt, policy: .showMsatsIfNonZero) + dailyLimit_parsedAmount = Result.success(newAmt.amount) + dailyLimit_amountStr = formattedAmt.digits + + } else { + + dailyLimit_parsedAmount = Result.failure(.emptyInput) + dailyLimit_amountStr = "" + } + } + + func monthlyLimit_currencyConverterAmountChanged(_ result: CurrencyAmount?) { + log.trace(#function) + + if let newAmt = result { + + let plus: [Currency] = [newAmt.currency, dailyLimit_currency] + let newCurrencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: plus) + if currencyList != newCurrencyList { + currencyList = newCurrencyList + } + + monthlyLimit_currency = newAmt.currency + monthlyLimit_currencyStr = newAmt.currency.shortName + + let formattedAmt = Utils.format(currencyAmount: newAmt, policy: .showMsatsIfNonZero) + monthlyLimit_parsedAmount = Result.success(newAmt.amount) + monthlyLimit_amountStr = formattedAmt.digits + + } else { + + monthlyLimit_parsedAmount = Result.failure(.emptyInput) + monthlyLimit_amountStr = "" + } + } + + func dailyLimit_currencyChanged() { + log.trace(#function) + + updateDailyCardPaymentsAmount() + maybeShowSaveToast() + } + + func monthlyLimit_currencyChanged() { + log.trace(#function) + + updateMonthlyCardPaymentsAmount() + maybeShowSaveToast() + } + + func dailyLimit_userDidEdit() { + log.trace(#function) + + // This is called if the user manually edits the TextField. + // Which is distinct from `amountDidChange`, which may be triggered via code. + + maybeShowSaveToast() + } + + func monthlyLimit_userDidEdit() { + log.trace(#function) + + // This is called if the user manually edits the TextField. + // Which is distinct from `amountDidChange`, which may be triggered via code. + + maybeShowSaveToast() + } + + func cardAmountsChanged() { + log.trace(#function) + + updateDailyCardPaymentsAmount() + updateMonthlyCardPaymentsAmount() + } + + // -------------------------------------------------- + // MARK: Utils + // -------------------------------------------------- + + func dailyLimit_currencyAmount() -> CurrencyAmount? { + + if case .success(let amount) = dailyLimit_parsedAmount { + return CurrencyAmount(currency: dailyLimit_currency, amount: amount) + } else { + return nil + } + } + + func monthlyLimit_currencyAmount() -> CurrencyAmount? { + + if case .success(let amount) = monthlyLimit_parsedAmount { + return CurrencyAmount(currency: dailyLimit_currency, amount: amount) + } else { + return nil + } + } + + func updateDailyCardPaymentsAmount() { + + guard let cardAmounts else { + dailyCardPaymentsAmount = 0.0 + log.debug("dailyCardPaymentsAmount = 0.0 (cardAmounts == nil)") + return + } + + switch dailyLimit_currency { + case .bitcoin(let bitcoinUnit): + let msat = cardAmounts.dailyBitcoinAmount() + dailyCardPaymentsAmount = Utils.convertBitcoin(msat: msat, to: bitcoinUnit) + log.debug("dailyCardPaymentsAmount = \(dailyCardPaymentsAmount) \(bitcoinUnit.shortName)") + + case .fiat(let fiatCurrency): + dailyCardPaymentsAmount = cardAmounts.dailyFiatAmount( + target: fiatCurrency, + exchangeRates: currencyPrefs.fiatExchangeRates + ) + log.debug("dailyCardPaymentsAmount = \(dailyCardPaymentsAmount) \(fiatCurrency.shortName)") + } + } + + func updateMonthlyCardPaymentsAmount() { + + guard let cardAmounts else { + monthlyCardPaymentsAmount = 0.0 + log.debug("monthlyCardPaymentsAmount = 0.0 (cardAmounts == nil)") + return + } + + switch dailyLimit_currency { + case .bitcoin(let bitcoinUnit): + let msat = cardAmounts.dailyBitcoinAmount() + monthlyCardPaymentsAmount = Utils.convertBitcoin(msat: msat, to: bitcoinUnit) + log.debug("monthlyCardPaymentsAmount = \(monthlyCardPaymentsAmount) \(bitcoinUnit.shortName)") + + case .fiat(let fiatCurrency): + monthlyCardPaymentsAmount = cardAmounts.dailyFiatAmount( + target: fiatCurrency, + exchangeRates: currencyPrefs.fiatExchangeRates + ) + log.debug("monthlyCardPaymentsAmount = \(monthlyCardPaymentsAmount) \(fiatCurrency.shortName)") + } + } + + // -------------------------------------------------- + // MARK: Tasks + // -------------------------------------------------- + + func fetchCardAmounts() async { + log.trace(#function) + + guard !cardInfo.isArchived else { + log.debug("fetchCardAmounts(): skipping: isArchived") + return + } + + let cardsDb: SqliteCardsDb + do { + cardsDb = try await Biz.business.databaseManager.cardsDb() + } catch { + log.error("SqliteCardsDb unavailable: \(error)") + return + } + + let cardPayments: SqliteCardsDb.CardPayments + do { + cardPayments = try await cardsDb.fetchCardPayments(cardId: cardInfo.id) + } catch { + log.error("SqliteCardsDb.fetchCardPayments(): error: \(error)") + return + } + + cardAmounts = cardsDb.getCardAmounts(payments: cardPayments) + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func navigateTo(_ tag: NavLinkTag) { + log.trace("navigateTo(\(tag.description))") + + if #available(iOS 17, *) { + navCoordinator.path.append(tag) + } else { + navLinkTag = tag + } + } + + func maybeShowSaveToast() { + log.trace(#function) + + guard !ignoreChanges else { + return + } + + if isFirstUserEdit { + isFirstUserEdit = false + + toast.pop( + "Changes take effect after you Save", + colorScheme: colorScheme.opposite, + style: .chrome, + duration: 5.0, + alignment: .top(padding: 0), + transition: .asymmetric(insertion: .push(from: .leading), removal: .move(edge: .trailing)), + showCloseButton: false + ) + } + } + + func cancelButtonTapped() { + log.trace(#function) + + if hasChanges && canSave { + showDiscardChangesConfirmationDialog = true + } else { + close() + } + } + + func saveButtonTapped() { + log.trace(#function) + + if hasChanges && canSave { + saveCard() + } else { + close() + } + } + + func saveCard() { + log.trace(#function) + + let updatedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let updatedIsFrozen = !isActive + let updatedDailyLimit = dailyLimit_currencyAmount()?.toSpendingLimit() + let updatedMonthlyLimit = monthlyLimit_currencyAmount()?.toSpendingLimit() + + Task { + do { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + + // Get the most recent version of the card. + // If a payment was made with the card while this screen was open, + // then we might not have the lastest version of `lastKnownCounter`. + // + let currentCard = cardsDb.cardForId(cardId: cardInfo.id) ?? cardInfo + let updatedCard = currentCard.doCopy( + id : currentCard.id, + name : updatedName, + keys : currentCard.keys, + uid : currentCard.uid, + lastKnownCounter : currentCard.lastKnownCounter, + isFrozen : updatedIsFrozen, + isArchived : currentCard.isArchived, + isReset : currentCard.isReset, + isForeign : currentCard.isForeign, + dailyLimit : updatedDailyLimit, + monthlyLimit : updatedMonthlyLimit, + createdAt : currentCard.createdAt + ) + + try await cardsDb.saveCard(card: updatedCard) + + } catch { + log.error("SqliteCardsDb.saveCard(): error: \(error)") + } + + self.close() + } + } + + func close() { + log.trace(#function) + + presentationMode.wrappedValue.dismiss() + } + + // -------------------------------------------------- + // MARK: Management Tasks + // -------------------------------------------------- + + func archiveCard() { + log.trace(#function) + + smartModalState.display(dismissable: false) { + ArchiveCardSheet(card: cardInfo, didArchive: { + cardInfo = $0 + cardWasArchived = true + }) + } + } + + func resetPhysicalCard() { + log.trace(#function) + + smartModalState.display(dismissable: false) { + ResetCardSheet(card: cardInfo, didRequestReset: { startResetPhysicalCardProcess() }) + } + } + + func deleteCard() { + log.trace(#function) + + smartModalState.display(dismissable: false) { + DeleteCardSheet(card: cardInfo, didDelete: { close() }) + } + } + + // -------------------------------------------------- + // MARK: Card Reset + // -------------------------------------------------- + + func startResetPhysicalCardProcess() { + log.trace(#function) + + let input = NfcWriter.ResetInput( + key0 : cardInfo.keys.key0_bytes, + piccDataKey : cardInfo.keys.piccDataKey_bytes, + cmacKey : cardInfo.keys.cmacKey_bytes + ) + + NfcWriter.shared.resetCard(input) { (result: Result) in + + switch result { + case .failure(let error): + log.debug("NfcWriter.resetCard(): error: \(error)") + showWriteErrorSheet(error) + + case .success(): + resetSuccess() + } + } + } + + func resetSuccess() { + log.trace(#function) + + // Step 1 of 2: + // Show the success screen + + smartModalState.display(dismissable: true) { + ResetSuccessSheet() + } + + // Step 2 of 2: + // Update the card in the database + Task { @MainActor in + do { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + + // Try to get the most recent version of the card, + // just in-case any changes were made elsewhere in the system. + // + let currentCard = cardsDb.cardForId(cardId: cardInfo.id) ?? cardInfo + let updatedCard = currentCard.resetCopy() + + try await cardsDb.saveCard(card: updatedCard) + cardInfo = updatedCard + cardWasReset = true + + } catch { + log.error("SqliteCardsDb.saveCard(): error: \(error)") + } + } + } + + func showWriteErrorSheet(_ error: NfcWriter.WriteError) { + log.trace(#function) + + var shouldIgnoreError = false + if case .scanningTerminated(let nfcError) = error { + shouldIgnoreError = nfcError.isIgnorable() + } + + guard !shouldIgnoreError else { + log.debug("showWriteErrorSheet(): ignoring standard user error") + return + } + + smartModalState.display(dismissable: true) { + WriteErrorSheet(error: error, context: .whileResetting) + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/NewCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/NewCardSheet.swift new file mode 100644 index 000000000..5dc682933 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/NewCardSheet.swift @@ -0,0 +1,94 @@ +import SwiftUI + +fileprivate let filename = "NewCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct NewCardSheet: View { + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 12) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + Text("Your card is now ready to use.") + .font(.title2.weight(.medium)) + .padding(.bottom, 8) + + Image(systemName: "checkmark.circle") + .resizable() + .scaledToFit() + .frame(width: 96, height: 96) + .foregroundColor(.appAccent) + .padding(.bottom, 24) + + Text("Use this screen to manage your card anytime you need.") + .font(.callout) + .padding(.bottom, 24) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 4) { + Text("Be your own bank").font(.headline) + + if #available(iOS 18, *) { + Image(systemName: "bitcoinsign.bank.building") + } else { + Image(systemName: "bitcoinsign.circle") + } + } + .padding(.bottom, 8) + + Text("Remember:") + .textCase(.uppercase) + .font(.subheadline) + .foregroundStyle(Color.secondary) + .padding(.bottom, 4) + + Text("Your bank (this device) needs to be online to process payments with your card.") + .font(.subheadline) + .foregroundStyle(Color.secondary) + .padding(.bottom, 16) + } + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + func closeButtonTapped() { + log.trace(#function) + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/PrerequisitesSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/PrerequisitesSheet.swift new file mode 100644 index 000000000..9435cedbb --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/PrerequisitesSheet.swift @@ -0,0 +1,87 @@ +import SwiftUI + +fileprivate let filename = "PrerequisitesSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct PrerequisitesSheet: View { + + @EnvironmentObject var deepLinkManager: DeepLinkManager + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Prerequisites") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 16) { + + Text( + """ + You need a BIP-353 address before you can create a card. + """ + ) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + Button { + goToExperimental() + } label: { + Text("Get address") + } + } + } + .padding(.top, 16) + .padding(.horizontal) + } + + func goToExperimental() { + log.trace("goToExperimental()") + + smartModalState.close(animationCompletion: { + deepLinkManager.broadcast(.bip353Registration) + }) + } + + func closeButtonTapped() { + log.trace("closeButtonTapped()") + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ReadCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ReadCardSheet.swift new file mode 100644 index 000000000..7d1ce4586 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ReadCardSheet.swift @@ -0,0 +1,429 @@ +import SwiftUI +import PhoenixShared +import CoreNFC +import DnaCommunicator + +fileprivate let filename = "ReadCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ReadCardSheet: View { + + let result: Result + + @State var scannedUri: URL? = nil + @State var scannedText: String? = nil + @State var scannedUnknown: Bool = false + + @State var errorMessage: String? = nil + + @State var isBoltCard: Bool = false + @State var matchingCard: BoltCardInfo? = nil + @State var piccDataInfo: Ntag424.PiccDataInfo? = nil + + @State var didAppear: Bool = false + + @EnvironmentObject var smartModalState: SmartModalState + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + .onAppear { + onAppear() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Read card") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + if let link = scannedUri?.absoluteString { + + let sanitizedLink = preventAutoHyphenation(link) + Button { + openScannedUri() + } label: { + Text(sanitizedLink) + .multilineTextAlignment(.leading) + } + + } else if let scannedText { + + let sanitizedText = preventAutoHyphenation(scannedText) + Text(sanitizedText) + .contextMenu { + Button { + copyScannedText() + } label: { + Text("Copy") + } + } // + + } else if scannedUnknown { + Text("Scanned NDEF tag with unknown type") + + } else if let errorMessage { + Text(errorMessage) + .multilineTextAlignment(.leading) + .foregroundStyle(Color.red) + } + + if isBoltCard { + boltCardDetails() + .padding(.top, 16) + } + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 32) + } + + @ViewBuilder + func boltCardDetails() -> some View { + + if let matchingCard, let piccDataInfo { + boltCardDetails(matchingCard, piccDataInfo) + } else { + Text("Card not associated with this wallet.") + } + } + + @ViewBuilder + func boltCardDetails(_ matchingCard: BoltCardInfo, _ piccDataInfo: Ntag424.PiccDataInfo) -> some View { + + Grid( + alignment: Alignment.trailing, + horizontalSpacing: 8, + verticalSpacing: 8 + ) { + GridRow { + HStack(spacing: 0) { + Text("Bolt Card:").bold() + Spacer() + } + .gridCellColumns(2) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - Name:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(matchingCard.sanitizedName) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - Status:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(cardStatus(matchingCard)) + .gridCellAnchor(.leading) + } + #if DEBUG && false + // For testing: make sure `func updateLastKnownCounter` is working properly. + GridRow { + Text(" - LastKnownCounter:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(matchingCard.lastKnownCounter.description) + .gridCellAnchor(.leading) + } + #endif + GridRow { + HStack(spacing: 0) { + Text("Picc Data:").bold() + Spacer() + } + .gridCellColumns(2) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - UID:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(piccDataInfo.uid.toHex(.upperCase)) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - Counter:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text(piccDataInfo.counter.description) + .gridCellAnchor(.leading) + } + GridRow { + HStack(spacing: 0) { + Text("Message Authentication Code:").bold() + Spacer() + } + .gridCellColumns(2) + .gridCellAnchor(.leading) + } + GridRow { + Text(" - Verified:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + Text("True") + .gridCellAnchor(.leading) + } + } + .padding() + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func preventAutoHyphenation(_ text: String) -> String { + + // The URL is long because of the query parameters. + // When SwiftUI displays long text, it automatically adds + // hyphen characters at the end of some lines. + // + // E.g. + // id=3fabbe50&picc_data=FB9B4202A7- <- added hyphen + // C37842120BE2D... + // + // I don't like this. And there's a simple way to prevent it. + // You just add zero-width characters in-between every character + // in the string. + // + // https://stackoverflow.com/q/78208090 + // + + return text.map({ String($0) }).joined(separator: "\u{200B}") + } + + func cardStatus(_ card: BoltCardInfo) -> String { + + if card.isArchived { + return String(localized: "Frozen (archived)") + } else if card.isFrozen { + return String(localized: "Frozen") + } else { + return String(localized: "Active") + } + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func onAppear() { + log.trace(#function) + + guard !didAppear else { + log.debug("onAppear(): ignoring: didAppear is true") + return + } + didAppear = true + + switch result { + case .failure(let failure): + switch failure { + case .readingNotAvailable: + errorMessage = "NFC cababilities not available on this device." + case .alreadyStarted: + errorMessage = "An NFC session is already running." + case .errorReadingTag: + errorMessage = "Error reading NDEF tag." + case .scanningTerminated(let nfcError): + errorMessage = "NFC reader error: \(nfcError.localizedDescription)" + } + case .success(let result): + log.debug("NFCNDEFMessage: \(result)") + + var detectedUri: URL? = nil + var detectedText: String? = nil + var detectedUnknown: Bool = false + + result.records.forEach { payload in + if let uri = payload.wellKnownTypeURIPayload() { + log.debug("found uri = \(uri)") + + if detectedUri == nil { + detectedUri = uri + } + + } else if let text = payload.wellKnownTypeTextPayload().0 { + log.debug("found text = \(text)") + + if detectedText == nil { + detectedText = text + } + + } else { + log.debug("found tag with unknown type") + detectedUnknown = true + + } + } + + if let detectedUri { + scannedUri = detectedUri + let result = Ntag424.extractQueryItems(url: detectedUri) + if case .success(let queryItems) = result { + isBoltCard = true + tryMatchCard(queryItems) + } + + } else if let detectedText { + scannedText = detectedText + let result = Ntag424.extractQueryItems(text: detectedText) + if case .success(let queryItems) = result { + isBoltCard = true + tryMatchCard(queryItems) + } + + } else if detectedUnknown { + scannedUnknown = true + + } else { + errorMessage = "No URI detected in NFC tag" + } + } + } + + // -------------------------------------------------- + // MARK: Utilities + // -------------------------------------------------- + + func tryMatchCard(_ queryItems: Ntag424.QueryItems) { + log.trace(#function) + + Task { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + let cards: [BoltCardInfo] = cardsDb.cardsListValue + + var matchingCard: BoltCardInfo? = nil + var piccDataInfo: Ntag424.PiccDataInfo? = nil + + for card in cards { + + let keySet = Ntag424.KeySet( + piccDataKey : card.keys.piccDataKey_data, + cmacKey : card.keys.cmacKey_data + ) + let result = Ntag424.extractPiccDataInfo( + piccData : queryItems.piccData, + cmac : queryItems.cmac, + keySet : keySet + ) + + switch result { + case .failure(let err): + log.debug("card[\(card.id)]: err: \(err)") + + case .success(let result): + log.debug("card[\(card.id)]: success") + + matchingCard = card + piccDataInfo = result + break + } + } + + guard let matchingCard, let piccDataInfo else { + return + } + + DispatchQueue.main.async { [matchingCard, piccDataInfo] in + self.matchingCard = matchingCard + self.piccDataInfo = piccDataInfo + } + + updateLastKnownCounter(matchingCard, piccDataInfo.counter) + } + } + + /// While we're here, we might as well take advantage, and update the card's `lastKnownCounter`. + /// Technically this could be considered a safety mechanism too. + /// For example, if you worry somebody may have scanned your card without your permission. + /// + func updateLastKnownCounter(_ matchingCard: BoltCardInfo, _ lastKnownCounter: UInt32) { + log.trace("updateLastKnownCounter(\(lastKnownCounter))") + + Task { @MainActor in + do { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + + let currentCard = cardsDb.cardForId(cardId: matchingCard.id) ?? matchingCard + let updatedCounter = max(currentCard.lastKnownCounter, lastKnownCounter) + let updatedCard = currentCard.withUpdatedLastKnownCounter(updatedCounter) + + try await cardsDb.saveCard(card: updatedCard) + } catch { + log.debug("SqliteCardsDb.saveCard(): error: \(error)") + } + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func openScannedUri() { + log.trace(#function) + + guard let uri = scannedUri else { + return + } + + if UIApplication.shared.canOpenURL(uri) { + UIApplication.shared.open(uri) + } + } + + func copyScannedText() { + log.trace(#function) + + if let scannedText { + UIPasteboard.general.string = scannedText + } + } + + func closeButtonTapped() { + log.trace(#function) + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetCardSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetCardSheet.swift new file mode 100644 index 000000000..3267dde42 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetCardSheet.swift @@ -0,0 +1,97 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ResetCardSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ResetCardSheet: View { + + let card: BoltCardInfo + let didRequestReset: () -> Void + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Reset card") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 16) { + + Text("This will clear the card, allowing it to be linked again with any wallet.") + + if !card.isArchived { + Text( + """ + Afterwards, the card will be Archived, and can never be activated again. \ + The card will remain in your list, but will be moved to the Archived section. + """ + ) + } + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + + Button { + cancelButtonTapped() + } label: { + Text("Cancel").font(.title3) + } + .padding(.trailing, 24) + + Button { + resetButtonTapped() + } label: { + Text("Reset").font(.title3).foregroundStyle(Color.red) + } + } + .padding(.top, 16) // extra padding + } + .padding(.top, 16) + .padding(.horizontal) + } + + func cancelButtonTapped() { + log.trace(#function) + + smartModalState.close() + } + + func resetButtonTapped() { + log.trace(#function) + + smartModalState.close(animationCompletion: { + didRequestReset() + }) + } +} + + diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetSuccessSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetSuccessSheet.swift new file mode 100644 index 000000000..db166c6ed --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/ResetSuccessSheet.swift @@ -0,0 +1,77 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ResetSuccessSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ResetSuccessSheet: View { + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 12) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + Text("Your card is now reset.") + .font(.title2.weight(.medium)) + .padding(.bottom, 8) + + Image(systemName: "checkmark.circle") + .resizable() + .scaledToFit() + .frame(width: 96, height: 96) + .foregroundColor(.appAccent) + .padding(.bottom, 24) + + Text("It can be linked again with any wallet.") + .font(.callout) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + func closeButtonTapped() { + log.trace(#function) + + smartModalState.close() + } +} + + + diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorPasteSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorPasteSheet.swift new file mode 100644 index 000000000..600daccf2 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorPasteSheet.swift @@ -0,0 +1,255 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "SimulatorPasteSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct SimulatorPasteSheet: View { + + let input: BoltCardInput + + @State var jsonInput: String = "" + + @EnvironmentObject var smartModalState: SmartModalState + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + .onChange(of: jsonInput) { _ in + jsonInputChanged() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Simulator instructions") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 32) { + + Text( + """ + The simulator doesn't support NFC. \ + But you can link a card to this wallet for testing. + """ + ) + .fixedSize(horizontal: false, vertical: true) // text truncation bugs + + content_instructions() + content_copy() + content_paste() + + } // + .frame(maxWidth: .infinity) + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 32) + } + + @ViewBuilder + func content_instructions() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + + Text("On a real device:") + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("Open Phoenix app (debug build)") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("Go to: Configuration > Bolt cards") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("Press and hold \"create new debit card\" button for 3 seconds") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("A sheet will appear to guide you through the process") + } + } + } + + @ViewBuilder + func content_copy() -> some View { + + Grid( + alignment: Alignment.trailing, + horizontalSpacing: 8, + verticalSpacing: 8 + ) { + GridRow { + Text("Copy simulator's info:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + + Button { + copySimInfoToClipboard() + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text(simulatorInfo()) + .lineLimit(1) + .truncationMode(.tail) + Image(systemName: "square.on.square") + } + } + .gridCellAnchor(.leading) + } + } + } + + @ViewBuilder + func content_paste() -> some View { + + TextField("Paste JSON output from device here", text: $jsonInput, axis: .vertical) + .lineLimit(3, reservesSpace: true) + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + } + + @ViewBuilder + func bullet() -> some View { + + Image(systemName: "circlebadge.fill") + .imageScale(.small) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .padding(.leading, 4) + .padding(.trailing, 8) + .offset(y: -3) + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func simulatorInfo() -> String { + + do { + let jsonData = try JSONEncoder().encode(input) + return String(data: jsonData, encoding: .utf8) ?? "" + } catch { + return "" + } + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func jsonInputChanged() { + log.trace("jsonInputChanged()") + + do { + let data = jsonInput.data(using: .utf8)! + let result = try JSONDecoder().decode(SimulatorBoltCardInput.self, from: data) + + importCard(result) + + } catch { + log.debug("Invalid JSON") + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func copySimInfoToClipboard() { + log.trace("copySimInfoToClipboard()") + + UIPasteboard.general.string = simulatorInfo() + } + + func importCard(_ input: SimulatorBoltCardInput) { + log.trace("importCard()") + + let key0_data = Data(fromHex: input.key0)! + let key0_vector = Bitcoin_kmpByteVector(bytes: key0_data.toKotlinByteArray()) + + let keySet = BoltCardKeySet(key0: key0_vector) + + let chipUid_data = Data(fromHex: input.chipUid)! + let chipUid_vector = Bitcoin_kmpByteVector(bytes: chipUid_data.toKotlinByteArray()) + + let cardInfo = BoltCardInfo( + id: Lightning_kmpUUID.companion.randomUUID(), + name: "", + keys: keySet, + uid: chipUid_vector, + lastKnownCounter: 0, + isFrozen: false, + isArchived: false, + isReset: false, + isForeign: false, + dailyLimit: nil, + monthlyLimit: nil, + createdAt: Date.now.toKotlinInstant() + ) + + Task { @MainActor in + do { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + try await cardsDb.saveCard(card: cardInfo) + smartModalState.close() + + } catch { + log.error("SqliteCardsDb.saveCard(): error: \(error)") + } + } + } + + func closeButtonTapped() { + log.trace("closeButtonTapped()") + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorWriteSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorWriteSheet.swift new file mode 100644 index 000000000..05652ee90 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/SimulatorWriteSheet.swift @@ -0,0 +1,281 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "SimulatorWriteSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct SimulatorWriteSheet: View { + + @State var input: String = "" + @State var jsonOutput: String = "" + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + .onChange(of: input) { _ in + inputChanged() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Simulator debugging") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 32) { + + Text("Link a card to a simulator wallet for testing.") + .fixedSize(horizontal: false, vertical: true) + + content_notes() + content_input() + if !jsonOutput.isEmpty { + content_json() + } + + } // + .frame(maxWidth: .infinity) + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 32) + } + + @ViewBuilder + func content_notes() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("This will create a new card that is linked to a wallet running on a simulator") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text("Note that simulators do not support background execution") + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text( + """ + So to make a payment using the card, the simulator must be open, \ + with Phoenix running in the foreground + """ + ) + } + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 0) { + bullet() + Text( + """ + The simulator must be running on a Mac with either Apple Silicon or \ + the T2 security chip (to receive push notifications) + """ + ) + } + } + } + + @ViewBuilder + func content_input() -> some View { + + Grid( + alignment: Alignment.trailing, + horizontalSpacing: 8, + verticalSpacing: 8 + ) { + GridRow { + Text("Simulator Info:") + .foregroundStyle(.secondary) + .gridCellAnchor(.trailing) + + TextField("Paste here", text: $input) + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + .gridCellAnchor(.leading) + } + } + } + + @ViewBuilder + func content_json() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 8) { + Text("Copy and paste into simulator:") + Button { + copyJsonToClipboard() + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text(jsonOutput) + Image(systemName: "square.on.square") + } + } + } + } + + @ViewBuilder + func bullet() -> some View { + + Image(systemName: "circlebadge.fill") + .imageScale(.small) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .padding(.leading, 4) + .padding(.trailing, 8) + .offset(y: -3) + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func inputChanged() { + log.trace("hexAddrStringChanged()") + + do { + let data = input.data(using: .utf8)! + let result = try JSONDecoder().decode(BoltCardInput.self, from: data) + + writeToNfcCard(result) + } catch { + log.debug("Invalid JSON") + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func writeToNfcCard(_ cardInput: BoltCardInput) { + log.trace("writeToNfcCard()") + + let template = cardInput.toTemplate() + + log.debug("template.value: \(template.valueString)") + log.debug("template.piccDataOffset: \(template.piccDataOffset)") + log.debug("template.cmacOffset: \(template.cmacOffset)") + + let keys = BoltCardKeySet.companion.random() + let input = NfcWriter.WriteInput( + template : template, + key0 : keys.key0_bytes, + piccDataKey : keys.piccDataKey_bytes, + cmacKey : keys.cmacKey_bytes + ) + + NfcWriter.shared.writeCard(input) { (result: Result) in + + switch result { + case .failure(let error): + log.debug("error: \(error)") + showWriteErrorSheet(error) + + case .success(let output): + log.debug("output.chipUid: \(output.chipUid.toHex())") + saveNewCard(keys, output) + } + } + } + + func saveNewCard( + _ keys: BoltCardKeySet, + _ output: NfcWriter.WriteOutput + ) { + + // Conversion madness: [UInt8] -> Data -> ByteArray -> ByteVector + let uid: Bitcoin_kmpByteVector = output.chipUid.toData().toKotlinByteVector() + + let cardInfo = BoltCardInfo(name: "", keys: keys, uid: uid, isForeign: true) + + Task { @MainActor in + do { + let cardsDb = try await Biz.business.databaseManager.cardsDb() + try await cardsDb.saveCard(card: cardInfo) + + let rawOutput = SimulatorBoltCardInput( + key0: keys.key0_bytes.toHex(.lowerCase), + chipUid: output.chipUid.toHex(.lowerCase) + ) + let jsonData = try JSONEncoder().encode(rawOutput) + jsonOutput = String(data: jsonData, encoding: .utf8) ?? "" + + } catch { + log.error("SqliteCardsDb.saveCard(): error: \(error)") + } + } + } + + func showWriteErrorSheet(_ error: NfcWriter.WriteError) { + log.trace(#function) + + var shouldIgnoreError = false + if case .scanningTerminated(let nfcError) = error { + shouldIgnoreError = nfcError.isIgnorable() + } + + guard !shouldIgnoreError else { + log.debug("showWriteErrorSheet(): ignoring standard user error") + return + } + + smartModalState.close(animationCompletion: { + smartModalState.display(dismissable: true) { + WriteErrorSheet(error: error, context: .whileWriting) + } + }) + } + + func copyJsonToClipboard() { + log.trace(#function) + + UIPasteboard.general.string = jsonOutput + } + + func closeButtonTapped() { + log.trace(#function) + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/WriteErrorSheet.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/WriteErrorSheet.swift new file mode 100644 index 000000000..76ebdd377 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/bolt card/WriteErrorSheet.swift @@ -0,0 +1,168 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "WriteErrorSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct WriteErrorSheet: View { + + enum Context { + case whileWriting + case whileResetting + } + + let error: NfcWriter.WriteError + let context: Context + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Write error") + .font(.title3) + .accessibilityAddTraits(.isHeader) + Spacer() + Button { + closeButtonTapped() + } label: { + Image("ic_cross") + .resizable() + .frame(width: 30, height: 30) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + } + + @ViewBuilder + func content() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 16) { + + Text("An error occurred while attempting to write to the NFC tag.") + + switch error { + case .readingNotAvailable: + Text("NFC capabilities not available on this device.").bold() + + case .alreadyStarted: + Text("An NFC session is already running.").bold() + + case .couldNotConnect: + Text("Could not connect to the NFC tag.").bold() + Text( + """ + Please try again. And be sure to hold the card close \ + to the phone until the writing process completes. + """ + ) + + case .couldNotAuthenticate: + Text("Could not authenticate with card.").bold() + switch context { + case .whileWriting: + Text( + """ + This card is already linked to another wallet. \ + To re-use this card you must first unlink the card. \ + In Phoenix there is an option called "reset physical card" which will unlink it. + """ + ) + case .whileResetting: + Text( + """ + This doesn't appear to be the linked card. \ + Perhaps this card is associated with a different wallet, \ + or a different card in this wallet. + """ + ) + } + + + case .keySlotsUnavailable: + Text("Key slots unavailable").bold() + switch context { + case .whileWriting: + Text( + """ + This card has been improperly programmed or reset too many times, \ + and it's now impossible to use the card. + """ + ) + case .whileResetting: + Text( + """ + An unknown error occurred while attempting to clear the keys from the card. \ + Please try resetting it again. If the problem persists, you may need to \ + destroy the card by cutting it up. + """ + ) + } + + case .protocolError(let writeStep, let error): + Text("Protocol error: \(writeStepName(writeStep))").bold() + Text("Details: \(error.localizedDescription)") + switch context { + case .whileWriting: + Text( + """ + The card is **NOT** ready to be used. \ + Please try writing it again. + """ + ) + case .whileResetting: + Text( + """ + An unexpected error occurred while attempting to reset the card. \ + Please try resetting it again. If the problem persists, you may need to \ + destroy the card by cutting it up. + """ + ) + } + + case .scanningTerminated(let nfcError): + Text("NFC process terminated unexpectedly").bold() + Text("NFC error: \(nfcError.localizedDescription)") + } + + } // + .padding(.horizontal) + .padding(.vertical, 16) + } + + func writeStepName(_ writeStep: NfcWriter.WriteStep) -> String { + switch writeStep { + case .readChipUid : return "Read Chip UID" + case .writeFile2Settings : return "Write File(2) Settings" + case .writeFile2Data : return "Write File(2) Data" + case .writeKey0 : return "Write Key(0)" + } + } + + func closeButtonTapped() { + log.trace("closeButtonTapped()") + + smartModalState.close() + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift index 28b0a84eb..374395619 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift @@ -619,6 +619,7 @@ struct WalletInfoView: View { case .finalWallet : newNavLinkTag = NavLinkTag.FinalWalletDetails case .appAccess : break case .walletMetadata : break + case .bip353Registration : break } if let newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift index f4dea1771..ec40c89c0 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift @@ -389,6 +389,7 @@ struct PaymentOptionsList: View { case .finalWallet : break case .appAccess : break case .walletMetadata : break + case .bip353Registration : break } if let newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift b/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift index 7d04e1756..d55aeabf3 100644 --- a/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift +++ b/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift @@ -21,6 +21,7 @@ enum DeepLink: Equatable, CustomStringConvertible { case finalWallet case appAccess case walletMetadata + case bip353Registration var description: String { return switch self { @@ -37,6 +38,7 @@ enum DeepLink: Equatable, CustomStringConvertible { case .finalWallet : "finalWallet" case .appAccess : "appAccess" case .walletMetadata : "walletMetadata" + case .bip353Registration : "bip353Registration" } } } diff --git a/phoenix-ios/phoenix-ios/views/inspect/Details/DetailsInfoGrid+CommonSections.swift b/phoenix-ios/phoenix-ios/views/inspect/Details/DetailsInfoGrid+CommonSections.swift index aed3e8662..79060a720 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/Details/DetailsInfoGrid+CommonSections.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/Details/DetailsInfoGrid+CommonSections.swift @@ -666,7 +666,7 @@ extension DetailsInfoGrid { identifier: #function, keyColumnTitle: "Offer" ) { - let text = invoice.invoiceRequest.offer.encode() + let text = invoice.offer.encode() Text(text) .lineLimit(5) .truncationMode(.tail) diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift index 434e4dce6..a7e6835f9 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift @@ -21,6 +21,8 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc private let verticalSpacingBetweenRows: CGFloat = 12 private let horizontalSpacingBetweenColumns: CGFloat = 8 + @State var card: BoltCardInfo? = nil + @State var popoverPresent_standardFees = false @State var popoverPresent_minerFees = false @State var popoverPresent_serviceFees = false @@ -61,6 +63,7 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc recipientRow() paymentTypeRow() channelClosingRow() + cardRow() paymentFeesRow_StandardFees() paymentFeesRow_MinerFees() @@ -70,6 +73,14 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc paymentErrorRow() } .padding([.leading, .trailing]) + .task(id: paymentInfo) { + if let cardId = paymentInfo.metadata.cardId { + let cardsDb = try? await Biz.business.databaseManager.cardsDb() + card = cardsDb?.cardForId(cardId: cardId) + } else { + card = nil + } + } } @ViewBuilder @@ -519,6 +530,27 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc } } + @ViewBuilder + func cardRow() -> some View { + let identifier: String = #function + + if let card { + InfoGridRow( + identifier: identifier, + vAlignment: .firstTextBaseline, + hSpacing: horizontalSpacingBetweenColumns, + keyColumnWidth: keyColumnWidth(identifier: identifier), + keyColumnAlignment: .trailing + ) { + keyColumn("Card") + + } valueColumn: { + Text(card.sanitizedName) + + } // + } + } + @ViewBuilder func paymentFeesRow_StandardFees() -> some View { diff --git a/phoenix-ios/phoenix-ios/views/main/HomeView.swift b/phoenix-ios/phoenix-ios/views/main/HomeView.swift index 524b09b9d..3a2cd4b82 100644 --- a/phoenix-ios/phoenix-ios/views/main/HomeView.swift +++ b/phoenix-ios/phoenix-ios/views/main/HomeView.swift @@ -831,6 +831,7 @@ struct HomeView : MVIView { case .finalWallet : break case .appAccess : break case .walletMetadata : break + case .bip353Registration : break } } } diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift b/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift index 17d2b63f3..012abb013 100644 --- a/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift +++ b/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift @@ -626,6 +626,7 @@ struct MainView_Big: View { case .finalWallet : showSettings() case .appAccess : showSettings() case .walletMetadata : showSettings() + case .bip353Registration : showSettings() } if #available(iOS 17, *) { @@ -680,6 +681,10 @@ struct MainView_Big: View { case .walletMetadata: navCoordinator_settings.path.removeAll() navCoordinator_settings.path.append(ConfigurationList.NavLinkTag.WalletMetadata) + + case .bip353Registration: + navCoordinator_settings.path.removeAll() + navCoordinator_settings.path.append(ConfigurationList.NavLinkTag.Experimental) } } } diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift b/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift index fbeb41867..dd04759ec 100644 --- a/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift +++ b/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift @@ -714,6 +714,10 @@ struct MainView_Small: View { case .walletMetadata: navCoordinator.path.append(NavLinkTag.ConfigurationView) navCoordinator.path.append(ConfigurationList.NavLinkTag.WalletMetadata) + + case .bip353Registration: + navCoordinator.path.append(NavLinkTag.ConfigurationView) + navCoordinator.path.append(ConfigurationList.NavLinkTag.Experimental) } } @@ -737,6 +741,7 @@ struct MainView_Small: View { case .finalWallet : newNavLinkTag = .ConfigurationView ; delay *= 2 case .appAccess : newNavLinkTag = .ConfigurationView ; delay *= 2 case .walletMetadata : newNavLinkTag = .ConfigurationView ; delay *= 2 + case .bip353Registration : newNavLinkTag = .ConfigurationView ; delay *= 2 } if let newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift b/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift index e561d2405..109990d81 100644 --- a/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift +++ b/phoenix-ios/phoenix-ios/views/receive/LightningDualView.swift @@ -1,6 +1,7 @@ import SwiftUI import PhoenixShared import CoreNFC +import DnaCommunicator fileprivate let filename = "LightningDualView" #if DEBUG && true @@ -48,8 +49,23 @@ struct LightningDualView: View { @State var modificationTitleType: ModifyInvoiceSheet.TitleType = .normal - @State var nfcEligible: Bool = false - @State var nfcActive: Bool = false + @State var hceEligible: Bool = false + @State var hceActive: Bool = false + + @State var cardPending: Bool = false + @State var cardErrorMessage: String? = nil + + enum CardState { + case scanning + case parsing + case requesting + case receiving + } + @State var cardState: CardState? = nil + @State var cardOfferId: Bitcoin_kmpByteVector32? = nil + @State var cardRequestId: Bitcoin_kmpByteVector? = nil + @State var cardRequestExpiration: Date? = nil + @State var currentDate = Date() // For the cicular buttons: [copy, share, edit] enum MaxButtonWidth: Preference {} @@ -107,6 +123,11 @@ struct LightningDualView: View { lastIncomingPaymentChanged(payment) } } + .task { + for await response in Biz.cardResponsePublisher.values { + cardResponseReceived(response) + } + } .onReceive(NotificationsManager.shared.permissions) { notificationPermissionsChanged($0) } @@ -128,10 +149,10 @@ struct LightningDualView: View { } // } .task { - // Don't show NFC button unless their device is eligible to use it. + // Don't show HCE (host card emulation) button unless their device is eligible to use it if #available(iOS 17.4, *) { if await CardSession.isEligible { - nfcEligible = true + hceEligible = true } } } @@ -176,6 +197,10 @@ struct LightningDualView: View { qrCodeWrapperView() + detailedInfo() + .padding(.top) + .padding(.horizontal, 20) + if let warning = inboundFeeState.calculateInboundFeeWarning(invoiceAmount: invoiceAmount()) { inboundFeeInfo(warning) .padding(.top) @@ -183,30 +208,42 @@ struct LightningDualView: View { } typePicker() + .padding(.top) .padding(.horizontal, 20) - .padding(.vertical) actionButtons() + .padding(.top) - detailedInfo() - .padding(.horizontal, 20) - .padding(.vertical) - - if activeType == .bolt12_offer, let address = bip353Address { - bip353AddressView(address) - .lineLimit(2) - .multilineTextAlignment(.center) - .font(.callout) - .padding(.top) - } + // Screen real estate is at a premium here. + // So if the user is doing NFC stuff, + // we really don't have to show other things that don't pertain to the current action. - if activeType == .bolt12_offer { - howToUseButton() + if hceActive { + hceActivity() .padding(.top) - } - - if nfcActive { - nfcActivity() + + } else if let state = cardState { + cardActivity(state) + .padding(.top) + + } else { + + if let msg = cardErrorMessage { + cardError(msg) + .padding(.top) + } + + if activeType == .bolt12_offer { + if let address = bip353Address { + bip353AddressView(address) + .lineLimit(2) + .multilineTextAlignment(.center) + .font(.callout) + .padding(.top) + } + howToUseButton() + .padding(.top) + } } if notificationPermissions == .disabled { @@ -351,12 +388,12 @@ struct LightningDualView: View { VStack(alignment: .center, spacing: 10) { invoiceAmountView() - .font(.callout) + .font(.body) .foregroundColor(.secondary) invoiceDescriptionView() .lineLimit(1) - .font(.callout) + .font(.body) .foregroundColor(.secondary) } // @@ -386,11 +423,8 @@ struct LightningDualView: View { @ViewBuilder func invoiceDescriptionView() -> some View { - let trimmedDesc = description.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - let finalDesc = trimmedDesc.isEmpty ? nil : trimmedDesc - - if let finalDesc { - Text(finalDesc) + if let trimmedDesc = trimmedDescription() { + Text(trimmedDesc) } else { Text("no description", comment: "placeholder: invoice is description-less") } @@ -416,15 +450,75 @@ struct LightningDualView: View { @ViewBuilder func actionButtons() -> some View { - HStack(alignment: VerticalAlignment.center, spacing: 30) { + ViewThatFits { + actionButtons_standard() + actionButtons_compact() + actionButtons_accessibility() + } + .assignMaxPreference(for: maxButtonWidthReader.key, to: $maxButtonWidth) + } + + @ViewBuilder + func actionButtons_standard() -> some View { + + HStack(alignment: VerticalAlignment.top, spacing: 0) { + Spacer() + copyButton() + Spacer() + shareButton() + Spacer() + editButton() + Spacer() + if hceEligible { + hceButton() + Spacer() + } + cardButton() + Spacer() + } + } + + @ViewBuilder + func actionButtons_compact() -> some View { + + HStack(alignment: VerticalAlignment.top, spacing: 0) { copyButton() + Spacer() shareButton() + Spacer() editButton() - if nfcEligible { - nfcButton() + Spacer() + if hceEligible { + hceButton() + Spacer() + } + cardButton() + } + } + + @ViewBuilder + func actionButtons_accessibility() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 20) { + HStack(alignment: VerticalAlignment.top, spacing: 0) { + Spacer() + copyButton() + Spacer() + shareButton() + Spacer() + editButton() + Spacer() + } + HStack(alignment: VerticalAlignment.top, spacing: 0) { + Spacer() + if hceEligible { + hceButton() + Spacer() + } + cardButton() + Spacer() } } - .assignMaxPreference(for: maxButtonWidthReader.key, to: $maxButtonWidth) } @ViewBuilder @@ -520,7 +614,7 @@ struct LightningDualView: View { } @ViewBuilder - func nfcButton() -> some View { + func hceButton() -> some View { actionButton( text: String(localized: "nfc", comment: "button label - try to make it short"), @@ -528,9 +622,23 @@ struct LightningDualView: View { width: 21, height: 21, xOffset: 0, yOffset: 0 ) { - didTapNfcButton() + didTapHceButton() } - .disabled(nfcActive) + .disabled(hceActive) + } + + @ViewBuilder + func cardButton() -> some View { + + actionButton( + text: String(localized: "card", comment: "button label - try to make it short"), + image: Image(systemName: "creditcard"), + width: 21, height: 21, + xOffset: 0, yOffset: 0 + ) { + didTapCardButton() + } + .disabled(cardState != nil) } @ViewBuilder @@ -544,20 +652,54 @@ struct LightningDualView: View { } @ViewBuilder - func nfcActivity() -> some View { + func hceActivity() -> some View { + + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + HorizontalActivity(color: .appAccent, diameter: 10, speed: 1.6) + .frame(width: 240, height: 10) + } + } + + @ViewBuilder + func cardActivity(_ state: CardState) -> some View { - if nfcActive { + VStack(alignment: HorizontalAlignment.center, spacing: 0) { - VStack(alignment: HorizontalAlignment.center, spacing: 0) { + HorizontalActivity(color: .appAccent, diameter: 10, speed: 1.6) + .frame(width: 240, height: 10) + .padding(.bottom, 4) - HorizontalActivity(color: .appAccent, diameter: 10, speed: 1.6) - .frame(width: 240, height: 10) - .padding(.horizontal) - .padding(.bottom, 4) - - } // - .padding(.top) - } + Group { + switch state { + case .scanning: + Text("Reading card…") + case .parsing: + Text("Communicating with card's host…") + case .requesting: + Text("Requesting payment…") + case .receiving: + Text("Awaiting payment…") + } + } + .multilineTextAlignment(.center) + + if let remaining = cardRequestRemainingTime() { + Text(remaining) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } + + } // + } + + @ViewBuilder + func cardError(_ errorMsg: String) -> some View { + + Text(errorMsg) + .multilineTextAlignment(.center) + .foregroundColor(.appNegative) + .padding(.horizontal, 10) } @ViewBuilder @@ -608,6 +750,12 @@ struct LightningDualView: View { // MARK: View Helpers // -------------------------------------------------- + func trimmedDescription() -> String? { + + let trimmedDesc = description.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + return trimmedDesc.isEmpty ? nil : trimmedDesc + } + func title() -> String { switch activeType { @@ -659,13 +807,38 @@ struct LightningDualView: View { return "..." } } + + func cardRequestRemainingTime() -> String? { + + guard let expiration = cardRequestExpiration else { + return nil + } + + let remaining = max(0.0, expiration.timeIntervalSince(currentDate)) + + // For the first 10 seconds, we just show the HorizontalActivity animation. + // Then, after we're down to 20 seconds left, we'll switch and show the timer countdown. + // + guard remaining < 21 else { + return nil + } + + let minutes = Int(remaining / 60.0) + let seconds = Int(remaining) % 60 + + let nf = NumberFormatter() + nf.minimumIntegerDigits = 2 + let secondsStr = nf.string(from: NSNumber(value: seconds)) ?? "00" + + return "\(minutes):\(secondsStr)" + } // -------------------------------------------------- // MARK: View Transitions // -------------------------------------------------- func onAppear() { - log.trace("onAppear()") + log.trace(#function) // Careful: this function may be called multiple times guard !didAppear else { @@ -681,15 +854,14 @@ struct LightningDualView: View { // -------------------------------------------------- func updateInvoiceOrOffer() { - log.trace("updateInvoiceOrOffer()") + log.trace(#function) - let trimmedDesc = description.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - let finalDesc = trimmedDesc.isEmpty ? nil : trimmedDesc + let trimmedDesc = trimmedDescription() if activeType == .bolt11_invoice { mvi.intent(Receive.IntentAsk( amount: amountMsat, - desc: finalDesc, + desc: trimmedDesc, expirySeconds: Prefs.current.invoiceExpirationSeconds )) @@ -702,9 +874,9 @@ struct LightningDualView: View { // Requirement in lightning-kmp: // > an offer description must be provided if the amount isn't null // - var fixedDesc = finalDesc + var fixedDesc = trimmedDesc if amountMsat != nil { - fixedDesc = finalDesc ?? "" + fixedDesc = trimmedDesc ?? "" } let offerAndKey = Lightning_kmpOfferManagerCompanion.shared.deterministicOffer( @@ -720,7 +892,7 @@ struct LightningDualView: View { } func updateQRCode() { - log.trace("updateQRCode()") + log.trace(#function) switch activeType { case .bolt11_invoice: @@ -759,6 +931,11 @@ struct LightningDualView: View { if activeType == .bolt11_invoice, model is Receive.Model_Generated { updateQRCode() + + if cardPending { + cardPending = false + startCardReader() + } } } @@ -793,7 +970,20 @@ struct LightningDualView: View { } } - } else if let _ = lightningPayment as? Lightning_kmpBolt12IncomingPayment { + } else if let b12Payment = lightningPayment as? Lightning_kmpBolt12IncomingPayment { + + if let cardOfferId { + // The user scanned a Bolt Card with a V2 value. + // So we generated a temporary offer, and sent it to the card holder's host/wallet, + // along with the CardParameters (encrypted data). + // + // We are therefore looking for an incoming Bolt12 payment, + // where the offerId matches our temporary.offerId. + // + if b12Payment.metadata.offerId == cardOfferId { + didCompletePayment = true + } + } if activeType == .bolt12_offer { didCompletePayment = true @@ -840,10 +1030,26 @@ struct LightningDualView: View { // - and if the activeType changes, we'll need to update the counterpart // needsUpdateInvoiceOrOffer = true + + if cardPending { + switch activeType { + case .bolt11_invoice: + // Handled in `modelChanged()` + break + case .bolt12_offer: + cardPending = false + startCardReader() + } + } + } func modifyInvoiceSheetDidCancel() { - log.trace("modifyInvoiceSheetDidCancel()") + log.trace(#function) + + if cardPending { + cardPending = false + } } func currencyConverterDidChange(_ amount: CurrencyAmount?) { @@ -883,6 +1089,38 @@ struct LightningDualView: View { } } + func cardResponseReceived(_ response: CardResponse) { + log.trace(#function) + + guard let cardRequestId else { + // We don't have a cardRequest pending; Doesn't pertain to us + return + } + guard cardRequestId == response.requestId else { + log.info("CardResponse.requestId mismatch: ignoring") + return + } + + self.cardState = nil + self.cardOfferId = nil + self.cardRequestId = nil + + if let errorCode = response.errorCode { // standardized error code + cardErrorMessage = String(localized: + """ + Card's wallet rejected payment request. + Error code: \(errorCode.rawValue) + Message: \(response.message) + """) + } else { + cardErrorMessage = String(localized: + """ + Card's wallet rejected payment request. + Message: \(response.message) + """) + } + } + // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- @@ -1014,8 +1252,8 @@ struct LightningDualView: View { } } - func didTapNfcButton() { - log.trace("didTapNfcButton()") + func didTapHceButton() { + log.trace(#function) // We're going to build a BIP-321 URI @@ -1074,6 +1312,31 @@ struct LightningDualView: View { } } } + + func didTapCardButton() { + log.trace(#function) + + if amountMsat == nil { + // We need the user to enter an amount first. + + cardPending = true + modificationTitleType = .cardPaymentNeedsAmount + smartModalState.display(dismissable: true) { + + ModifyInvoiceSheet( + titleType: modificationTitleType, + savedAmount: $modificationAmount, + description: $description, + openCurrencyConverter: openCurrencyConverter, + didSave: modifyInvoiceSheetDidSave, + didCancel: modifyInvoiceSheetDidCancel + ) + } + + } else { + startCardReader() + } + } func navigationToBackgroundPayments() { log.trace("navigateToBackgroundPayments()") @@ -1102,6 +1365,46 @@ struct LightningDualView: View { } } + func didCopyLink() { + log.trace(#function) + + toast.pop( + NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite + ) + } + + func startTimerForCardRequest() { + log.trace(#function) + + guard let requestId = cardRequestId else { + return + } + guard let expiration = cardRequestExpiration else { + return + } + + Task { + while true { + try await Task.sleep(seconds: 0.5) + + if cardRequestId != requestId { + break + } + + currentDate = Date.now + if expiration < currentDate { + cardState = nil + cardOfferId = nil + cardRequestId = nil + cardRequestExpiration = nil + cardErrorMessage = String(localized:"Card's wallet didn't respond to payment request.") + break + } + } + } + } + // -------------------------------------------------- // MARK: Utilities // -------------------------------------------------- @@ -1147,20 +1450,21 @@ struct LightningDualView: View { @available(iOS 17.4, *) func startHostCardEmulation(_ url: URL) { - log.trace("startHostCardEmulation()") + log.trace(#function) - let file = Ndef.ndefDataForUrl(url) + let dataInfo = Ndef.ndefDataForUrl(url) - nfcActive = true + hceActive = true Task { @MainActor in - if let error = await HceWriter.shared.start(ndefFile: file) { + if let error = await HceWriter.shared.start(ndefFile: dataInfo.data) { handleHceWriterError(error) } - nfcActive = false + hceActive = false } } func handleHceWriterError(_ error: HceWriterError) { + log.trace(#function) let msg: String switch error { @@ -1207,4 +1511,303 @@ struct LightningDualView: View { showCloseButton: true ) } + + // -------------------------------------------------- + // MARK: Card Payment + // -------------------------------------------------- + + func startCardReader() { + log.trace(#function) + + guard amountMsat != nil else { + log.debug("\(#function): ignoring: amount not set") + return + } + + cardState = .scanning + NfcReader.shared.readCard { result in + + cardState = nil + switch result { + case .failure(let failure): + switch failure { + case .readingNotAvailable: + cardErrorMessage = String(localized: "NFC cababilities not available on this device") + case .alreadyStarted: + cardErrorMessage = String(localized: "NFC reader is already scanning") + case .scanningTerminated(_): + cardErrorMessage = String(localized: "Nothing scanned") + case .errorReadingTag: + cardErrorMessage = String(localized: "Error reading tag") + } + + case .success(let message): + log.debug("NFCNDEFMessage: \(message)") + + if let result = BoltCardScan.parse(message) { + cardErrorMessage = nil + + switch result { + case .v1(let v1): + if let v2 = v1.v2 { + handleV2(v2) + } else { + handleV1(v1) + } + case .v2(let v2): + handleV2(v2) + } + + } else { + log.debug("BoltCardScan.parse() => nil") + + cardErrorMessage = String(localized: "Bolt Card not detected in NFC tag") + } + } + } + } + + // -------------------------------------------------- + // MARK: Card Payment: V1 + // -------------------------------------------------- + + func handleV1(_ v1: BoltCardScan.V1) { + log.trace("handleV1(\(v1.url.absoluteString)") + + cardState = .parsing + Task { @MainActor in + do { + let progressHandler = {(progress: SendManager.ParseProgress) -> Void in + // nothing to do here currently + } + + let result: SendManager.ParseResult = try await Biz.business.sendManager.parse( + request: v1.url.absoluteString, + progress: progressHandler + ) + + cardState = nil + handleV1_ParseResult(v1, result) + + } catch { + log.error("handleScannedUri: error: \(error)") + + cardState = nil + cardErrorMessage = String(localized: "Could not communicate with card's wallet") + } + + } // + } + + func handleV1_ParseResult(_ v1: BoltCardScan.V1, _ result: SendManager.ParseResult) { + log.trace(#function) + + guard let expectedResult = result as? SendManager.ParseResult_Lnurl_Withdraw else { + handleParseError(result) + return + } + guard let msat = amountMsat else { + log.error("\(#function): precondition failed: amount not set") + return + } + guard let peer = Biz.business.peerManager.peerStateValue() else { + log.error("\(#function): peer not available") + return + } + + cardState = .requesting + Task { @MainActor in + do { + + // We need a Bolt 11 invoice, which we may already have. + // Note: mvi.model is outdated when activeType is bolt12_offer. + let invoice: Lightning_kmpBolt11Invoice + if activeType == .bolt11_invoice, let model = mvi.model as? Receive.Model_Generated { + invoice = model.invoice + } else { + invoice = try await peer._createInvoice( + amount: msat, + description: trimmedDescription() ?? "", + expiryInSeconds: Prefs.current.invoiceExpirationSeconds + ) + } + + let err: SendManager.LnurlWithdrawError? = + try await Biz.business.sendManager.lnurlWithdraw_sendInvoice( + lnurlWithdraw: expectedResult.lnurlWithdraw, + invoice: invoice + ) + + if let remoteErr = err as? SendManager.LnurlWithdrawErrorRemoteError { + cardState = nil + handleV1_RequestError(remoteErr) + } else { + cardState = .receiving + } + + } catch { + log.error("handleParseResult: error: \(error)") + + cardState = nil + cardErrorMessage = String(localized: "Cound not communicate with card's wallet") + } + } // + } + + func handleV1_RequestError(_ result: SendManager.LnurlWithdrawErrorRemoteError) { + log.trace(#function) + + let remoteFailure = result.err + switch remoteFailure { + + case is LnurlError.RemoteFailure_CouldNotConnect: + cardErrorMessage = String( + localized: "Could not connect to card's host", + comment: "Error message - processing bolt card payment" + ) + + case is LnurlError.RemoteFailure_Unreadable: + cardErrorMessage = String( + localized: "Unreadable response from card's host", + comment: "Error message - processing bolt card payment" + ) + + case let rfDetailed as LnurlError.RemoteFailure_Detailed: + cardErrorMessage = String( + localized: "The card's host returned error message: \(rfDetailed.reason)", + comment: "Error message - processing bolt card payment" + ) + + case let rfCode as LnurlError.RemoteFailure_Code: + cardErrorMessage = String( + localized: "The card's host returned error code: \(rfCode.code.value)", + comment: "Error message - processing bolt card payment" + ) + + default: + cardErrorMessage = String( + localized: "Could not communicate with card's wallet", + comment: "Error message - scanning lightning invoice" + ) + } + } + + // -------------------------------------------------- + // MARK: Card Payment: V2 + // -------------------------------------------------- + + func handleV2(_ v2: BoltCardScan.V2) { + log.trace("handleV2(\(v2.baseText))") + + cardState = .parsing + Task { @MainActor in + do { + let progressHandler = {(progress: SendManager.ParseProgress) -> Void in + // nothing to do here currently + } + + let result: SendManager.ParseResult = try await Biz.business.sendManager.parse( + request: v2.baseText, + progress: progressHandler + ) + + cardState = nil + handleV2_ParseResult(v2, result) + + } catch { + log.error("handleV2: error: \(error)") + + cardState = nil + cardErrorMessage = String(localized: "Could not communicate with card's wallet") + } + + } // + } + + func handleV2_ParseResult(_ v2: BoltCardScan.V2, _ result: SendManager.ParseResult) { + log.trace("handleV2_ParseResult()") + + guard let bolt12Offer = result as? SendManager.ParseResult_Bolt12Offer else { + handleParseError(result) + return + } + guard let msat = amountMsat else { + log.error("handleV2_ParseResult(): precondition failed: amount not set") + return + } + guard let peer = Biz.business.peerManager.peerStateValue() else { + log.error("handleV2_ParseResult(): peer not available") + return + } + + let desc = trimmedDescription() ?? String(localized: "Card payment") + + cardState = .requesting + Task { @MainActor in + do { + let paymentInfo: Lightning_kmp_corePeer.CardPaymentInfo = + try await peer._requestCardPayment( + amount: msat, + description: desc, + timeoutInSeconds: 30, + cardHolderOffer: bolt12Offer.offer, + cardParams: v2.parametersText + ) + + cardState = .receiving + cardOfferId = paymentInfo.offerId + cardRequestId = paymentInfo.requestId + cardRequestExpiration = Date.now.addingTimeInterval(30) + + startTimerForCardRequest() + + } catch { + log.error("handleV2_ParseResult: error: \(error)") + + cardState = nil + cardErrorMessage = String(localized: "Cound not communicate with card's host") + } + } // + } + + // -------------------------------------------------- + // MARK: Card Payment: Errors + // -------------------------------------------------- + + func handleParseError(_ result: SendManager.ParseResult) { + log.trace(#function) + + var msg = String(localized: "Does not appear to be a bolt card.") + var websiteLink: URL? = nil + + if let badRequest = result as? SendManager.ParseResult_BadRequest { + + if let serviceError = badRequest.reason as? SendManager.BadRequestReason_ServiceError { + + let remoteFailure: LnurlError.RemoteFailure = serviceError.error + let origin = remoteFailure.origin + + if remoteFailure is LnurlError.RemoteFailure_IsWebsite { + websiteLink = URL(string: serviceError.url.description()) + msg = String( + localized: "Unreadable response from service: \(origin)", + comment: "Error message - scanning lightning invoice" + ) + } + } + } + + if let websiteLink { + popoverState.display(dismissable: true) { + WebsiteLinkPopover( + link: websiteLink, + didCopyLink: didCopyLink, + didOpenLink: nil + ) + } + + } else { + cardErrorMessage = msg + } + } } diff --git a/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift b/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift index 69a23c5d6..e0dce4889 100644 --- a/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift +++ b/phoenix-ios/phoenix-ios/views/receive/ModifyInvoiceSheet.swift @@ -273,7 +273,7 @@ struct ModifyInvoiceSheet: View { refreshAltAmount() } - currencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: currency) + currencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: [currency]) currencyPickerChoice = CurrencyPickerOption.currency(currency) smartModalState.onNextDidDisappear { diff --git a/phoenix-ios/phoenix-ios/views/send/PayOfferProblem.swift b/phoenix-ios/phoenix-ios/views/send/PayOfferProblem.swift index f2006a3c1..b8aabd26d 100644 --- a/phoenix-ios/phoenix-ios/views/send/PayOfferProblem.swift +++ b/phoenix-ios/phoenix-ios/views/send/PayOfferProblem.swift @@ -23,12 +23,8 @@ enum PayOfferProblem { } static func fromResponse( - _ response: Lightning_kmpOfferNotPaid? - ) -> PayOfferProblem? { - - guard let response else { - return nil - } + _ response: Lightning_kmpOfferNotPaid + ) -> PayOfferProblem { switch onEnum(of: response.reason) { case .noResponse(_) : return PayOfferProblem.noResponse diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index 2c8e344d1..a8bb0286f 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -1637,8 +1637,7 @@ struct ValidateView: View { if let newAmt = result { - let newCurrencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: newAmt.currency) - + let newCurrencyList = Currency.displayable(currencyPrefs: currencyPrefs, plus: [newAmt.currency]) if currencyList != newCurrencyList { currencyList = newCurrencyList } @@ -1921,8 +1920,8 @@ struct ValidateView: View { paymentInProgress = false - if let problem = PayOfferProblem.fromResponse(response) { - payOfferProblem = problem + if let response { + payOfferProblem = PayOfferProblem.fromResponse(response) Biz.endLongLivedTask(id: paymentId.description()) } else { diff --git a/phoenix-ios/phoenix-ios/views/widgets/HorizontalActivity.swift b/phoenix-ios/phoenix-ios/views/widgets/HorizontalActivity.swift index ef5e74bbe..652fff597 100644 --- a/phoenix-ios/phoenix-ios/views/widgets/HorizontalActivity.swift +++ b/phoenix-ios/phoenix-ios/views/widgets/HorizontalActivity.swift @@ -15,25 +15,25 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) struct HorizontalActivity: View { - init(color: Color, diameter: CGFloat, speed: TimeInterval = 1.6) { - self.color = color - self.diameter = diameter - self.speed = speed - - self.timer = Timer.publish(every: speed, on: .main, in: .common).autoconnect() - } - let color: Color let diameter: CGFloat let speed: TimeInterval - let timer: Publishers.Autoconnect + @State private var timer: Publishers.Autoconnect @State private var viewWidth: CGFloat? = nil @State private var offsetA: CGFloat = 0 @State private var offsetB: CGFloat = 0 @State private var showCircles = false + init(color: Color, diameter: CGFloat, speed: TimeInterval = 1.6) { + self.color = color + self.diameter = diameter + self.speed = speed + + self.timer = Timer.publish(every: speed, on: .main, in: .common).autoconnect() + } + struct HorizontalActivityWidth: PreferenceKey { typealias Value = [CGFloat] static var defaultValue: Value { [] } diff --git a/phoenix-ios/phoenix-ios/withdraw/CardRequest.swift b/phoenix-ios/phoenix-ios/withdraw/CardRequest.swift new file mode 100644 index 000000000..f1d50686f --- /dev/null +++ b/phoenix-ios/phoenix-ios/withdraw/CardRequest.swift @@ -0,0 +1,57 @@ +import Foundation +import PhoenixShared + +struct CardRequest { + let piccData: Data + let cmac: Data + let invoice: Lightning_kmpBolt12Invoice + let amount: Lightning_kmpMilliSatoshi + + static func fromOnionMessage(_ msg: Lightning_kmp_coreCardPaymentRequestReceived) -> CardRequest? { + + var piccStr: String? = nil + var cmacStr: String? = nil + + let comps = msg.cardParams.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: "&") + for comp in comps { + + let keyValue = comp.split(separator: "=") + if keyValue.count == 2 { + let key = keyValue[0].lowercased() + let value = keyValue[1].lowercased() + + if key == "picc_data" || key == "picc" || key == "piccdata" { + piccStr = value + + } else if key == "cmac" { + cmacStr = value + } + } + } + + var piccData: Data? = nil + var cmacData: Data? = nil + + if let piccStr { + piccData = Data(fromHex: piccStr) + } + if let cmacStr { + cmacData = Data(fromHex: cmacStr) + } + + if let piccData, let cmacData, let amount = msg.invoice.amount { + return CardRequest(piccData: piccData, cmac: cmacData, invoice: msg.invoice, amount: amount) + } else { + return nil + } + } + + func toWithdrawRequest() -> WithdrawRequest { + return WithdrawRequest( + piccData: self.piccData, + cmac: self.cmac, + method: .bolt12Invoice(invoice: self.invoice), + amount: self.amount + ) + } +} diff --git a/phoenix-ios/phoenix-ios/withdraw/CardResponse.swift b/phoenix-ios/phoenix-ios/withdraw/CardResponse.swift new file mode 100644 index 000000000..961261196 --- /dev/null +++ b/phoenix-ios/phoenix-ios/withdraw/CardResponse.swift @@ -0,0 +1,43 @@ +import Foundation +import PhoenixShared + +struct CardResponse: Hashable { + let code: Int64 + let message: String + let requestId: Bitcoin_kmpByteVector + + var errorCode: ErrorCode? { + return ErrorCode(rawValue: code) + } + + static func fromOnionMessage(_ onionMsg: Lightning_kmp_coreCardPaymentResponseReceived) -> CardResponse { + + let rawMsg = onionMsg.message + + let splits = rawMsg.split(separator: ":", maxSplits: 1) + if splits.count == 2 { + // The first item should be an integer + let codeStr = splits[0].trimmingCharacters(in: .whitespacesAndNewlines) + if let code = Int64.init(codeStr) { + let msg = splits[1].trimmingCharacters(in: .whitespacesAndNewlines) + + return CardResponse(code: code, message: msg, requestId: onionMsg.requestId) + } + } + + // Couldn't parse a code, so will use default value + return CardResponse(code: 0, message: rawMsg, requestId: onionMsg.requestId) + } + + /// Standardized error codes from specification. + enum ErrorCode: Int64 { + case unknownCard = 1 + case replayDetected = 2 + case frozenCard = 3 + case limitExceeded = 4 + case badInvoice = 5 + case alreadyPaidInvoice = 6 + case paymentPending = 7 + case internalError = 8 + } +} diff --git a/phoenix-ios/phoenix-ios/withdraw/WithdrawRequest.swift b/phoenix-ios/phoenix-ios/withdraw/WithdrawRequest.swift new file mode 100644 index 000000000..03ba9c0c7 --- /dev/null +++ b/phoenix-ios/phoenix-ios/withdraw/WithdrawRequest.swift @@ -0,0 +1,558 @@ +import Foundation +import PhoenixShared +import CryptoKit +import DnaCommunicator + +fileprivate let filename = "WithdrawRequest" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct WithdrawRequest { + let piccData: Data + let cmac: Data + let method: WithdrawRequestMethod + let amount: Lightning_kmpMilliSatoshi + let databaseHash: String + + init(piccData: Data, cmac: Data, method: WithdrawRequestMethod, amount: Lightning_kmpMilliSatoshi) { + self.piccData = piccData + self.cmac = cmac + self.method = method + self.amount = amount + self.databaseHash = Self.calculateDatabaseHash( + piccData: piccData, cmac: cmac, method: method, amount: amount + ) + } + + /// We use a hash to mark the request as "processed" within the database. + /// The hash encompasses all the relavent parts of the request. + /// + private static func calculateDatabaseHash( + piccData : Data, + cmac : Data, + method : WithdrawRequestMethod, + amount : Lightning_kmpMilliSatoshi + ) -> String { + + var hashMe = Data() + hashMe.append(piccData.toHex(.lowerCase).data(using: .utf8)!) + hashMe.append(cmac.toHex(.lowerCase).data(using: .utf8)!) + hashMe.append(method.encode().data(using: .utf8)!) + hashMe.append(String(amount.msat).data(using: .utf8)!) + + let digest = SHA256.hash(data: hashMe) + return digest.toHex(.lowerCase) + } +} + +enum WithdrawRequestMethod { + case bolt11Invoice(invoice: Lightning_kmpBolt11Invoice) + case bolt12Invoice(invoice: Lightning_kmpBolt12Invoice) + + func encode() -> String { + switch self { + case .bolt11Invoice(let invoice): + return invoice.write() + + case .bolt12Invoice(let invoice): + return invoice.write() + } + } + + var description: String? { + switch self { + case .bolt11Invoice(let invoice): + return invoice.description_ + + case .bolt12Invoice(let invoice): + return invoice.description_ + } + } +} + +enum WithdrawRequestStatus { + case continueAndSendPayment( + card : BoltCardInfo, + method : WithdrawRequestMethod, + amount : Lightning_kmpMilliSatoshi + ) + case abortHandledElsewhere(card: BoltCardInfo) +} + +enum WithdrawRequestError: Error, CustomStringConvertible { + case unknownCard + case replayDetected(card: BoltCardInfo) + case frozenCard(card: BoltCardInfo) + case dailyLimitExceeded(card: BoltCardInfo, amount: CurrencyAmount) + case monthlyLimitExceeded(card: BoltCardInfo, amount: CurrencyAmount) + case badInvoice(card: BoltCardInfo, details: String) + case alreadyPaidInvoice(card: BoltCardInfo) + case paymentPending(card: BoltCardInfo) + case internalError(card: BoltCardInfo?, details: String) + + var description: String { + return switch self { + case .unknownCard : "unknown card" + case .replayDetected : "replay detected" + case .frozenCard : "frozen card" + case .dailyLimitExceeded : "daily limit exceeded" + case .monthlyLimitExceeded : "monthly limit exceeded" + case .badInvoice(_, let details) : "bad invoice: \(details)" + case .alreadyPaidInvoice : "already paid invoice" + case .paymentPending : "payment pending" + case .internalError(_, let details) : "internal error: \(details)" + } + } + + var cardResponseMessage: String { + return switch self { + case .unknownCard : "unknown card" + case .replayDetected : "replay detected" + case .frozenCard : "frozen card" + case .dailyLimitExceeded : "limit exceeded" // don't expose daily/monthly type + case .monthlyLimitExceeded : "limit exceeded" // don't expose daily/monthly type + case .badInvoice(_, let details) : "bad invoice: \(details)" + case .alreadyPaidInvoice : "already paid invoice" + case .paymentPending : "payment pending" + case .internalError(_, _) : "internal error" // don't expose internal error details + } + } + + var cardResponseCode: CardResponse.ErrorCode { + return switch self { + case .unknownCard : CardResponse.ErrorCode.unknownCard + case .replayDetected(_) : CardResponse.ErrorCode.replayDetected + case .frozenCard(_) : CardResponse.ErrorCode.frozenCard + case .dailyLimitExceeded(_, _) : CardResponse.ErrorCode.limitExceeded + case .monthlyLimitExceeded(_, _) : CardResponse.ErrorCode.limitExceeded + case .badInvoice(_, _) : CardResponse.ErrorCode.badInvoice + case .alreadyPaidInvoice(_) : CardResponse.ErrorCode.alreadyPaidInvoice + case .paymentPending(_) : CardResponse.ErrorCode.paymentPending + case .internalError(_, _) : CardResponse.ErrorCode.internalError + } + } +} + +extension PhoenixBusiness { + + @MainActor + func checkWithdrawRequest( + _ request: WithdrawRequest + ) async -> Result { + + log.trace(#function) + + // Step 1 of 7: + // Decrypt the piccData & verify the cmac values. + // + // Note that the user may have multiple cards, + // and we don't know which card is sending the request. + // So we simply make an attempt with each linked card. + + let cardsDb: SqliteCardsDb + do { + cardsDb = try await self.databaseManager.cardsDb() + } catch { + return .failure(.internalError(card: nil, details: "card database unavailable")) + } + + var cards: [BoltCardInfo] = cardsDb.cardsListValue + if cards.isEmpty { + // The cardsList instance may not be ready yet. + // So we work around this by directly querying the database. + // + do { + cards = try await cardsDb.listCards() + } catch { + log.error("appDb.listCards(): error: \(error)") + } + } + + log.debug("cards.count = \(cards.count)") + + var matchingCard: BoltCardInfo? = nil + var piccDataInfo: Ntag424.PiccDataInfo? = nil + + for card in cards { + + if card.isForeign { + // We only manage foreign cards. + // They cannot be used for payments from this wallet. + continue + } + + let keySet = Ntag424.KeySet( + piccDataKey : card.keys.piccDataKey_data, + cmacKey : card.keys.cmacKey_data + ) + let result = Ntag424.extractPiccDataInfo( + piccData : request.piccData, + cmac : request.cmac, + keySet : keySet + ) + + switch result { + case .failure(let err): + log.debug("card[\(card.id)]: err: \(err)") + + case .success(let result): + log.debug("card[\(card.id)]: success") + + matchingCard = card + piccDataInfo = result + break + } + } + + guard let matchingCard, let piccDataInfo else { + return .failure(.unknownCard) + } + + // Step 2 of 7: + // Check to make sure the counter has been incremented. + + guard piccDataInfo.counter > matchingCard.lastKnownCounter else { + return .failure(.replayDetected(card: matchingCard)) + } + + // From this point forward: + // + // The last step we should perform, before returning the result, + // is updating the CardInfo within the database. + // We want to ensure we update the `lastKnownCounter` value + // to protect against replay attacks. + + let asyncDeferred = { @MainActor (result: Result) async + -> Result in + + var shouldUpdateCard = true + if case .success(let status) = result { + if case .abortHandledElsewhere = status { + shouldUpdateCard = false + } + } + + if shouldUpdateCard { + let updatedCard = matchingCard.withUpdatedLastKnownCounter(piccDataInfo.counter) + do { + try await cardsDb.saveCard(card: updatedCard) + } catch { + log.error("cardsManager.saveCard(): error: \(error)") + } + } + + return result + } + + // Step 3 of 7: + // Check to make sure the card isn't frozen. + + guard matchingCard.isActive else { + log.debug("card[\(matchingCard.id)]: isFrozen") + return await asyncDeferred(.failure(.frozenCard(card: matchingCard))) + } + + // Step 4 of 7: + // Validate the invoice. + // + // We know the invoice is technically valid (not malformed), + // but there are additional checks we need to perform such as: + // + // - chain mismatch (e.g. invoice is for mainnet but we're on testnet) + // - invoice is expired + // - already paid invoice + // - invoice has payment pending + // + // The SendManager has standardized code to perform these checks. + + do { + let badRequestReason: SendManager.BadRequestReason? + + switch request.method { + case .bolt11Invoice(let invoice): + badRequestReason = try await self.sendManager.checkForBadBolt11Invoice(invoice: invoice) + + case .bolt12Invoice(let invoice): + badRequestReason = try await self.sendManager.checkForBadBolt12Invoice(invoice: invoice) + } + + if let badRequestReason { + log.debug("SendManager.BadRequestReason: \(badRequestReason)") + + switch onEnum(of: badRequestReason) { + case .alreadyPaidInvoice(_): + return await asyncDeferred(.failure(.alreadyPaidInvoice(card: matchingCard))) + + case .paymentPending(_): + return await asyncDeferred(.failure(.paymentPending(card: matchingCard))) + + case .expired(_): + return await asyncDeferred(.failure(.badInvoice(card: matchingCard, details: "expired"))) + + case .chainMismatch(_): + return await asyncDeferred(.failure(.badInvoice(card: matchingCard, details: "chain mismatch"))) + + default: + return await asyncDeferred(.failure(.badInvoice(card: matchingCard, details: "parse error"))) + } + } + + } catch { + log.error("SendManager.checkForBadBolt1XInvoice: threw error: \(error)") + return await asyncDeferred(.failure(.internalError(card: matchingCard, details: "validation error"))) + } + + // Step 5 of 7: + // Check the amount against any set daily/monthly spending limits. + + let checkSpendingLimit = {( + cardAmounts : SqliteCardsDb.CardAmounts, + limit : CurrencyAmount, + isDaily : Bool + ) -> WithdrawRequestError? in + + let invoiceMsat: Int64 = request.amount.msat + + switch limit.currency { + case .bitcoin(let bitcoinUnit): + let limitMsat: Int64 = Utils.toMsat(from: limit.amount, bitcoinUnit: bitcoinUnit) + + let prvSpendMsat: Int64 = isDaily + ? cardAmounts.dailyBitcoinAmount().msat + : cardAmounts.monthlyBitcoinAmount().msat + + let newSpendMsat: Int64 = prvSpendMsat + invoiceMsat + + log.debug( + """ + \(isDaily ? "dailySpendingLimit" : "monthlySpendingLimit"): \ + prvSpendMsat(\(prvSpendMsat)) + invoiceMsat(\(invoiceMsat)) = \ + newSpendMsat(\(newSpendMsat)) ?>? limitMsat(\(limitMsat)) + """) + + if newSpendMsat > limitMsat { + let targetAmt = Utils.convertBitcoin(msat: invoiceMsat, to: bitcoinUnit) + let currencyAmt = CurrencyAmount(currency: limit.currency, amount: targetAmt) + + return isDaily + ? .dailyLimitExceeded(card: matchingCard, amount: currencyAmt) + : .monthlyLimitExceeded(card: matchingCard, amount: currencyAmt) + } + + case .fiat(let fiatCurrency): + let limitFiat: Double = limit.amount + + let exchangeRates = self.phoenixGlobal.currencyManager.ratesFlowValue + guard let exchangeRate = Utils.exchangeRate(for: fiatCurrency, fromRates: exchangeRates) else { + return .internalError(card: matchingCard, details: "missing exchange rate") + } + let invoiceFiat: Double = Utils.convertToFiat(msat: invoiceMsat, exchangeRate: exchangeRate) + + let prvSpendFiat: Double = isDaily + ? cardAmounts.dailyFiatAmount(target: fiatCurrency, exchangeRates: exchangeRates) + : cardAmounts.monthlyFiatAmount(target: fiatCurrency, exchangeRates: exchangeRates) + + let newSpendFiat: Double = prvSpendFiat + invoiceFiat + + log.debug( + """ + \(isDaily ? "dailySpendingLimit" : "monthlySpendingLimit"): \ + prvSpendFiat(\(prvSpendFiat)) + invoiceFiatt(\(invoiceFiat)) = \ + newSpendFiat(\(newSpendFiat)) ?>? limitFiat(\(limitFiat)) + """) + + if newSpendFiat > limitFiat { + let targetAmt = CurrencyAmount(currency: limit.currency, amount: invoiceFiat) + + return isDaily + ? .dailyLimitExceeded(card: matchingCard, amount: targetAmt) + : .monthlyLimitExceeded(card: matchingCard, amount: targetAmt) + } + } + + return nil + } + + if matchingCard.dailyLimit != nil || matchingCard.monthlyLimit != nil { + + do { + let cardPayments: SqliteCardsDb.CardPayments = + try await cardsDb.fetchCardPayments(cardId: matchingCard.id) + + let cardAmounts = cardsDb.getCardAmounts(payments : cardPayments) + + if let dailyLimit = matchingCard.dailyLimit?.toCurrencyAmount() { + if let error = checkSpendingLimit(cardAmounts, dailyLimit, true) { + return await asyncDeferred(.failure(error)) + } + } + if let monthlyLimit = matchingCard.monthlyLimit?.toCurrencyAmount() { + if let error = checkSpendingLimit(cardAmounts, monthlyLimit, false) { + return await asyncDeferred(.failure(error)) + } + } + + } catch { + return await asyncDeferred(.failure( + .internalError(card: matchingCard, details: "checking spending limits") + )) + } + } + + // Step 6 of 7: + // Wait until our peer is connected & all channels are ready. + // + // Note that there are safety mechanisms in place to ensure that + // only one process (mainPhoenixApp vs notifySrvExt) is able to + // connect to the peer at a time. + // That's why this step must preceed the following step. + + let target = AppConnectionsDaemon.ControlTarget.companion.Peer + for try await connections in self.connectionsManager.connectionsSequence() { + + log.debug("connections = \(connections)") + if connections.targetsEstablished(target) { + log.debug("Connected to peer") + break + } + } + + for try await channels in self.peerManager.channelsArraySequence() { + let allChannelsReady = channels.allSatisfy { $0.isTerminated || $0.isUsable } + if allChannelsReady { + log.debug("All channels ready") + break + } else { + log.debug("One or more channels not ready...") + } + } + + // Step 7 of 7: + // Atomically mark request as handled. + // + // At this point we've decided that it's safe to pay the invoice. + // The only question is WHO is going to pay it: + // - mainPhoenixApp (foreground process / main app with user interface) + // - notifySrvExt (background process that could be running in response to a notification) + // + // So to be sure we don't accidentally pay an invoice TWICE, + // we have an atomic database method that will fail if the other + // process has already marked it as handled. + + let handledByUs = await self.phoenixGlobal.appDb.tryMarkHandled(request) + + if handledByUs { + return await asyncDeferred(.success(.continueAndSendPayment( + card: matchingCard, method: request.method, amount: request.amount + ))) + } else { + // The payment is being handled else. + // Or has already been handled elsewhere. + // Probably by the notifySrvExt. + // + // So we need to abort processing (do NOT pay invoice). + // + return await asyncDeferred(.success(.abortHandledElsewhere(card: matchingCard))) + } + } +} + +extension SqliteAppDb { + + enum ProcessId: String, Codable { + case phoenixApp = "phoenixApp" + case notifySrvExt = "notifySrvExt" + } + + struct WithdrawRequestHandler: Codable { + let hash: String + let process: ProcessId + let date: Date + } + + @MainActor + func tryMarkHandled(_ request: WithdrawRequest) async -> Bool { + + let process: ProcessId + switch AppIdentifier.current { + case .foreground: process = .phoenixApp + case .background: process = .notifySrvExt + } + + let key = "WithdrawRequestHandlers" + do { + while true { + let existing: KotlinPair? = try await self.getValue(key: key) + + var handlers: [WithdrawRequestHandler] = [] + if let existing, let existingData = existing.first?.toSwiftData() { + + handlers = try JSONDecoder().decode([WithdrawRequestHandler].self, from: existingData) + } + + log.debug("tryMarkHandled(): existing handlers.count = \(handlers.count)") + + let isHandledAlready = handlers.contains(where: { (item: WithdrawRequestHandler) in + item.hash == request.databaseHash + }) + + if isHandledAlready { + log.debug("tryMarkHandled(): isHandledAlready") + return false + } + + if !handlers.isEmpty { + // Cleanup: remove any handlers older than 7 days + + let oldDate = Date.now.addingTimeInterval(60 * 60 * 24 * -7) + handlers.removeAll(where: { item in + item.date < oldDate + }) + + log.debug("tryMarkHandled(): post-clean: handlers.count = \(handlers.count)") + } + + handlers.append(WithdrawRequestHandler( + hash : request.databaseHash, + process : process, + date : Date.now + )) + + log.debug("tryMarkHandled(): new handlers.count = \(handlers.count)") + + let updatedData = try JSONEncoder().encode(handlers) + let lastUpdated: KotlinLong? = existing?.second + + log.debug("tryMarkHandled(): lastUpdated = \(lastUpdated?.description ?? "")") + + let result = try await setValueIfUnchanged( + value : updatedData.toKotlinByteArray(), + key : key, + lastUpdated : lastUpdated + ) + + log.debug("tryMarkHandled(): result = \(result?.description ?? "")") + + if result != nil { + return true + } else { + // The call to setValueIfUnchanged failed. + // But that could happen for a number of reasons: + // - background app marked this request as handled + // - background app marked a different request as handled + // - foreground app marked a different request as handled + // + // So we need to start the process over again. + } + + } // + + } catch { + log.error("tryMarkHandled(): error: \(error)") + return false + } + } +} diff --git a/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift b/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift index c5fda68f2..c682315d4 100644 --- a/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift +++ b/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift @@ -46,6 +46,10 @@ class NotificationService: UNNotificationServiceExtension { private var isConnectedToPeer = false private var receivedPayments: [Lightning_kmpIncomingPayment] = [] + private var withdrawRequestResult: Result? = nil + private var withdrawResponseSent: Bool = false + private var sentPayment: Lightning_kmpLightningOutgoingPayment? = nil + private var totalTimer: Timer? = nil private var connectionTimer: Timer? = nil private var postReceivedPaymentTimer: Timer? = nil @@ -104,13 +108,17 @@ class NotificationService: UNNotificationServiceExtension { // - Amazon Web Services (AWS) (only used for debugging) if let notification = PushNotification.parse(userInfo) { - + self.pushNotification = notification switch notification { case .fcm(let notification): processNotification_fcm(notification) + case .lnurlWithdraw(let notification): + Task { + await processNotification_lnurlWithdraw(notification) + } } - + } else { log.warning("processNotification(): Failed to parse userInfo as PushNotification") @@ -124,6 +132,106 @@ class NotificationService: UNNotificationServiceExtension { targetNodeIdHash = notification.nodeIdHash } + @MainActor + private func processNotification_lnurlWithdraw( + _ request: LnurlWithdrawNotification + ) async { + log.trace(#function) + + guard let business else { + log.warning("\(#function): business is nil") + return + } + + let reject = { @MainActor (error: WithdrawRequestError) async -> Void in + + // Stop other processing + self.stopPhoenix() + self.stopXpc() + + // Send the response to the merchant + let _ = await request.postResponse(errorReason: error.description) + + // And finally, display notification to the user + self.finish() + } + + let result = await business.checkWithdrawRequest(request.toWithdrawRequest()) + withdrawRequestResult = result + + switch result { + case .failure(let error): + log.error("\(#function): error: \(error.description)") + await reject(error) + + case .success(let status): + switch status { + case .abortHandledElsewhere: + log.warning("\(#function): abort: handled elsewhere") + finish() + + case .continueAndSendPayment(let card, _, _): + log.debug("\(#function): continue: send payment") + + guard + let peer = business.peerManager.peerStateValue(), + let defaultTrampolineFees = peer.walletParams.trampolineFees.first + else { + return await reject(.internalError(card: card, details: "peer is nil")) + } + + // Send the payment + do { + try await business.sendManager.payBolt11Invoice( + amountToSend : request.invoiceAmount, + trampolineFees : defaultTrampolineFees, + invoice : request.invoice, + metadata : WalletPaymentMetadata.withCard(card.id) + ) + } catch { + log.error("SendManager.payBolt11Invoice(): threw error: \(error)") + return await reject(.internalError(card: card, details: "payBolt11Invoice failed")) + } + + // We have 2 tasks to finish before we're done: + // 1). Send the response to the merchant + // 2). Wait for our outgoing payment to complete + // + // We can perform these in parallel. + + Task { @MainActor in + let _ = await request.postResponse(errorReason: nil) + + self.withdrawResponseSent = true + log.debug("withdrawResponseSent = true") + + if self.sentPayment != nil { + self.finish() + } + }.store(in: &cancellables) + + Task { @MainActor in + for await payment in business.paymentsManager.lastCompletedPaymentSequence() { + if let lnPayment = payment as? Lightning_kmpLightningOutgoingPayment, + let details = lnPayment.details as? Lightning_kmpLightningOutgoingPayment.DetailsNormal + { + if details.paymentHash == request.invoice.paymentHash { + self.sentPayment = lnPayment + log.debug("sentPayment = \(lnPayment)") + + if self.withdrawResponseSent { + self.finish() + break + } + } + } + } + }.store(in: &cancellables) + + } // + } // + } + // -------------------------------------------------- // MARK: Timers // -------------------------------------------------- @@ -325,6 +433,24 @@ class NotificationService: UNNotificationServiceExtension { self?.didReceivePayment(payment) } }.store(in: &cancellables) + + Task { @MainActor [newBusiness, weak self] in + let peer = try await newBusiness.peerManager.getPeer() + for await event in peer.eventsFlow { + if let msg = event as? Lightning_kmp_coreCardPaymentRequestReceived { + log.debug("found event: CardPaymentRequestReceived") + + if let cardRequest = CardRequest.fromOnionMessage(msg) { + Task { @MainActor [weak self] in + await self?.handleCardRequest(cardRequest) + } + } else { + log.debug("CardRequest.fromOnionMessage() failed") + } + } + } + + }.store(in: &cancellables) } private func stopPhoenix() { @@ -361,12 +487,91 @@ class NotificationService: UNNotificationServiceExtension { } } + @MainActor + private func handleCardRequest( + _ cardRequest: CardRequest + ) async { + log.trace(#function) + + guard let business else { + log.warning("handleCardRequest: business is nil") + return + } + + let reject = { @MainActor (error: WithdrawRequestError) async -> Void in + + // Stop other processing + self.stopPhoenix() + self.stopXpc() + + // Display notification to the user + self.finish() + } + + let result = await business.checkWithdrawRequest(cardRequest.toWithdrawRequest()) + withdrawRequestResult = result + + switch result { + case .failure(let error): + log.error("\(#function): error: \(error.description)") + + // Send error message to merchant + do { + let peer = try await business.peerManager.getPeer() + try await peer.sendCardResponse( + request : cardRequest.invoice, + msg : error.cardResponseMessage, + code : error.cardResponseCode.rawValue + ) + } catch { + log.error("peer.sendCardResponse(): error: \(error)") + } + + await reject(error) + + case .success(let status): + switch status { + case .abortHandledElsewhere(_): + log.warning("\(#function): abort: handled elsewhere") + + case .continueAndSendPayment(let card, _, _): + log.debug("\(#function): continue: send payment") + + // Send payment to merchant + do { + try await business.sendManager.payUnsolicitedInvoice( + invoice: cardRequest.invoice, + metadata: WalletPaymentMetadata.withCard(card.id) + ) + } catch { + log.error("peer.payUnsolicitedInvoice(): error: \(error)") + } + + // Wait for the outgoing payment to complete + Task { @MainActor [weak self] in + for await payment in business.paymentsManager.lastCompletedPaymentSequence() { + if let lnPayment = payment as? Lightning_kmpLightningOutgoingPayment { + if lnPayment.details.paymentHash == cardRequest.invoice.paymentHash { + self?.sentPayment = lnPayment + log.debug("sentPayment = \(lnPayment)") + + self?.finish() + } else { + log.debug("!sentPayment: \(lnPayment)") + } + } + } + }.store(in: &cancellables) + } + } + } + // -------------------------------------------------- // MARK: Finish // -------------------------------------------------- private func finish() { - log.trace("finish()") + log.trace(#function) assertMainThread() guard !srvExtDone else { @@ -375,7 +580,7 @@ class NotificationService: UNNotificationServiceExtension { srvExtDone = true guard let contentHandler, let remoteNotificationContent else { - log.error("finish(): invalid state") + log.error("\(#function): invalid state") return } @@ -426,13 +631,17 @@ class NotificationService: UNNotificationServiceExtension { } case .incomingOnionMessage: - // This was probably an incoming Bolt 12 payment. + // This was probably: + // - an incoming Bolt 12 payment + // - or a CardPayment request // But it could be anything, so let's code defensively. if let item = NotificationServiceQueue.shared.dequeue() { updateNotificationContent_localNotification(content, item) } else if let payment = popFirstReceivedPayment() { updateNotificationContent_receivedPayment(content, payment) + } else if let result = withdrawRequestResult { + updateNotificationContent_outgoingPayment(content, result) } else { updateNotificationContent_unknown(content) } @@ -443,6 +652,14 @@ class NotificationService: UNNotificationServiceExtension { case .unknown: updateNotificationContent_unknown(content) } + + case .lnurlWithdraw(_): + + if let result = withdrawRequestResult { + updateNotificationContent_outgoingPayment(content, result) + } else { + updateNotificationContent_unknown(content) + } } } else { @@ -496,15 +713,16 @@ class NotificationService: UNNotificationServiceExtension { content.title = String(localized: "Received payment", comment: "Push notification title") let groupPrefs = PhoenixManager.shared.groupPrefs() + let discreetNotifications = groupPrefs?.discreetNotifications ?? false - if let groupPrefs, !groupPrefs.discreetNotifications { + if !discreetNotifications { let paymentInfo = WalletPaymentInfo( payment: payment, metadata: WalletPaymentMetadata.empty(), contact: nil ) - let amountString = formatAmount(groupPrefs, msat: payment.amount.msat) + let amountString = formatAmount(msat: payment.amount.msat) if let desc = paymentInfo.paymentDescription(), desc.count > 0 { content.body = "\(amountString): \(desc)" } else { @@ -540,6 +758,144 @@ class NotificationService: UNNotificationServiceExtension { content.body = String(localized: "An incoming settlement is pending.", comment: "") } + private func updateNotificationContent_outgoingPayment( + _ content: UNMutableNotificationContent, + _ result: Result + ) { + log.trace(#function) + + switch result { + case .failure(let error): + content.title = String(localized: "Payment rejected") + + switch error { + case .unknownCard: + content.body = String(localized: "Unknown bolt card") + + case .replayDetected(let card): + content.body = String(localized: + """ + Replay attempt detected + Card: \(card.sanitizedName) + """) + + case .frozenCard(let card): + content.body = String(localized: + """ + Card is frozen + Card: \(card.sanitizedName) + """) + + case .dailyLimitExceeded(let card, let amount): + let amtStr = Utils.format(currencyAmount: amount).string + content.body = String(localized: + """ + Daily limit exceeded + Payment amount: \(amtStr) + Card: \(card.sanitizedName) + """) + + case .monthlyLimitExceeded(let card, let amount): + let amtStr = Utils.format(currencyAmount: amount).string + content.body = String(localized: + """ + Monthly limit exceeded + Payment amount: \(amtStr) + Card: \(card.sanitizedName) + """) + + case .badInvoice(let card, let details): + content.body = String(localized: + """ + Bad invoice: \(details) + Card: \(card.sanitizedName) + """) + + case .alreadyPaidInvoice(let card): + content.body = String(localized: + """ + You've already paid this invoice + Card: \(card.sanitizedName) + """) + + case .paymentPending(let card): + content.body = String(localized: + """ + A payment for this invoice is in-flight + Card: \(card.sanitizedName) + """) + + case .internalError(let card, let details): + if let card { + content.body = String(localized: + """ + Internal error: \(details) + Card: \(card.sanitizedName) + """) + } else { + content.body = String(localized: + """ + Internal error: \(details) + """) + } + } + + case .success(let status): + switch status { + case .abortHandledElsewhere(let card): + content.title = String(localized: "Payment attempt ignored") + content.subtitle = card.sanitizedName + content.body = String(localized: "Handled elsewhere in the system") + + case .continueAndSendPayment(let card, let method, let amount): + + if let sentPayment, let failedStatus = sentPayment.status.asFailed() { + + content.title = String(localized: "Payment attempt failed") + + let localizedReason = failedStatus.reason.localizedDescription() + + if failedStatus.reason is Lightning_kmpFinalFailure.InsufficientBalance { + let amountString = formatAmount(msat: amount.msat) + content.body = String(localized: + """ + \(localizedReason) + Payment amount: \(amountString) + Card: \(card.sanitizedName) + """) + + } else { + content.body = String(localized: + """ + \(localizedReason) + Card: \(card.sanitizedName) + """) + } + + } else { + + content.title = String(localized: "Payment successful 💳") + let amountString = formatAmount(msat: amount.msat) + + if let desc = method.description { + content.body = String(localized: + """ + \(amountString) + For: \(desc) + Card: \(card.sanitizedName) + """) + } else { + content.body = String(localized: + """ + \(amountString) + Card: \(card.sanitizedName) + """) + } + } + } // + } // + } + private func updateNotificationContent_unknown( _ content: UNMutableNotificationContent ) { @@ -564,20 +920,30 @@ class NotificationService: UNNotificationServiceExtension { } } - private func formatAmount(_ groupPrefs: GroupPrefs_Wallet, msat: Int64) -> String { - - let bitcoinUnit = groupPrefs.bitcoinUnit - let fiatCurrency = groupPrefs.fiatCurrency - let exchangeRate = PhoenixManager.shared.exchangeRate(fiatCurrency: fiatCurrency) - - let bitcoinAmt = Utils.formatBitcoin(msat: msat, bitcoinUnit: bitcoinUnit) - var amountString = bitcoinAmt.string + private func formatAmount(msat: Int64) -> String { - if let exchangeRate { - let fiatAmt = Utils.formatFiat(msat: msat, exchangeRate: exchangeRate) - amountString += " (≈\(fiatAmt.string))" + if let groupPrefs = PhoenixManager.shared.groupPrefs() { + + let bitcoinUnit = groupPrefs.bitcoinUnit + let fiatCurrency = groupPrefs.fiatCurrency + let exchangeRate = PhoenixManager.shared.exchangeRate(fiatCurrency: fiatCurrency) + + let bitcoinAmt = Utils.formatBitcoin(msat: msat, bitcoinUnit: bitcoinUnit) + var amountString = bitcoinAmt.string + + if let exchangeRate { + let fiatAmt = Utils.formatFiat(msat: msat, exchangeRate: exchangeRate) + amountString += " (≈\(fiatAmt.string))" + } + + return amountString + + } else { + + // Something is wrong - but let's at least display some amount. + // We can default to showing the amount in sats. + + return Utils.formatBitcoin(msat: msat, bitcoinUnit: .sat).string } - - return amountString } } diff --git a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt index 5a4bd98ef..10ff98278 100644 --- a/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt +++ b/phoenix-shared/src/androidMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt @@ -12,6 +12,9 @@ actual fun didUpdateWalletPaymentMetadata(id: UUID, database: PaymentsDatabase) actual fun didSaveContact(contactId: UUID, database: PaymentsDatabase) {} actual fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) {} +actual fun didSaveCard(cardId: UUID, database: PaymentsDatabase) {} +actual fun didDeleteCard(cardId: UUID, database: PaymentsDatabase) {} + actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? { return null } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixGlobal.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixGlobal.kt index 87f435660..da9dbac42 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixGlobal.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/PhoenixGlobal.kt @@ -36,7 +36,7 @@ class PhoenixGlobal(val ctx: PlatformContext) { val loggerFactory = LoggerFactory(PhoenixLoggerConfig(ctx)) private val logger = loggerFactory.newLogger(this::class) - val appDb by lazy { SqliteAppDb(createAppDbDriver(ctx)) } + val appDb by lazy { SqliteAppDb(loggerFactory, createAppDbDriver(ctx)) } val networkMonitor = NetworkMonitor(loggerFactory, ctx) val currencyManager by lazy { CurrencyManager(loggerFactory, appDb) } val feerateManager by lazy { FeerateManager(loggerFactory) } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt index c4923aab3..efbdd2f16 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Receive.kt @@ -1,6 +1,7 @@ package fr.acinq.phoenix.controllers.payments import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.phoenix.controllers.MVI object Receive { @@ -8,7 +9,13 @@ object Receive { sealed class Model : MVI.Model() { object Awaiting : Model() object Generating: Model() - data class Generated(val request: String, val paymentHash: String, val amount: MilliSatoshi?, val desc: String?): Model() + data class Generated( + val invoice: Bolt11Invoice, + val request: String, + val paymentHash: String, + val amount: MilliSatoshi?, + val desc: String? + ): Model() } sealed class Intent : MVI.Intent() { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt index 22fe15523..8f06b8f3c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ReceiveController.kt @@ -51,7 +51,13 @@ class AppReceiveController( description = Either.Left(intent.description), expiry = intent.expirySeconds.seconds ) - model(Receive.Model.Generated(paymentRequest.write(), paymentRequest.paymentHash.toHex(), paymentRequest.amount, paymentRequest.description)) + model(Receive.Model.Generated( + invoice = paymentRequest, + request = paymentRequest.write(), + paymentHash = paymentRequest.paymentHash.toHex(), + amount = paymentRequest.amount, + desc = paymentRequest.description + )) } } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BoltCardInfo.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BoltCardInfo.kt new file mode 100644 index 000000000..2b3b15077 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BoltCardInfo.kt @@ -0,0 +1,105 @@ +package fr.acinq.phoenix.data + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.Crypto +import fr.acinq.lightning.Lightning +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.toByteVector +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +data class BoltCardInfo( + val id: UUID, + val name: String, + val keys: BoltCardKeySet, + val uid: ByteVector, + val lastKnownCounter: UInt, + val isFrozen: Boolean, + val isArchived: Boolean, + val isReset: Boolean, + val isForeign: Boolean, + val dailyLimit: SpendingLimit?, + val monthlyLimit: SpendingLimit?, + val createdAt: Instant +) { + init { + require(uid.size() == UID_SIZE) { "Invalid uid size: ${uid.size()} != $UID_SIZE" } + } + + constructor( + name: String, + keys: BoltCardKeySet, + uid: ByteVector, + isForeign: Boolean = false + ) : this( + id = UUID.randomUUID(), + name = name, + keys = keys, + uid = uid, + lastKnownCounter = 0u, + isFrozen = false, + isArchived = false, + isReset = false, + isForeign = isForeign, + dailyLimit = null, + monthlyLimit = null, + createdAt = Clock.System.now() + ) + + companion object { + /** UID size in bytes. */ + const val UID_SIZE = 7 + + /** + * Useful for debugging & unit testing. + * Note that the UID of a card is programmed into the chip (immutable). + */ + fun randomUid() = Lightning.randomBytes(length = UID_SIZE).toByteVector() + } +} + +data class BoltCardKeySet( + val key0: ByteVector +) { + init { + require(key0.size() == KEY_SIZE) { "Invalid key size: ${key0.size()} != $KEY_SIZE" } + } + + val piccDataKey: ByteVector by lazy { + keyGen("piccDataKey") + } + + val cmacKey: ByteVector by lazy { + keyGen("cmacKey") + } + + private fun keyGen(keyId: String): ByteVector { + val inner: ByteArray = sha256Hash(key0.toByteArray()) + val outer: ByteArray = sha256Hash(keyId.toByteArray(Charsets.UTF_8)) + + val hashMe: ByteArray = outer + inner + outer + return sha256Hash(hashMe).toByteVector().take(KEY_SIZE) + } + + private fun sha256Hash(bytes: ByteArray): ByteArray { + return Crypto.sha256(bytes) + } + + companion object { + /** Key size in bytes. */ + const val KEY_SIZE = 16 + + fun random() = BoltCardKeySet( + key0 = Lightning.randomBytes(length = KEY_SIZE).toByteVector() + ) + } +} + +data class SpendingLimit( + val currency: CurrencyUnit, + val amount: Double +) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt index 36f90af1b..03236878e 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt @@ -1,6 +1,7 @@ package fr.acinq.phoenix.data import fr.acinq.lightning.db.WalletPayment +import fr.acinq.lightning.utils.UUID import fr.acinq.phoenix.data.lnurl.LnurlPay /** @@ -23,6 +24,7 @@ data class WalletPaymentMetadata( val userDescription: String? = null, val userNotes: String? = null, val lightningAddress: String? = null, + val cardId: UUID? = null, val modifiedAt: Long? = null ) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbHooks.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbHooks.kt index 09a4eb687..15c201e7b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbHooks.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/DbHooks.kt @@ -47,6 +47,22 @@ expect fun didSaveContact(contactId: UUID, database: PaymentsDatabase) */ expect fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) +/** + * Implement this function to execute platform specific code when a contact is saved to the database. + * For example, on iOS this is used to enqueue the (encrypted) contact for upload to CloudKit. + * + * This function is invoked inside the same transaction used to add/modify the row. + * This means any database operations performed in this function are atomic, + * with respect to the referenced row. + */ +expect fun didSaveCard(cardId: UUID, database: PaymentsDatabase) + +/** + * Implement this function to execute platform specific code when a card is deleted. + * For example, on iOS this is used to enqueue an operation to delete the card from CloudKit. + */ +expect fun didDeleteCard(cardId: UUID, database: PaymentsDatabase) + /** * Implemented on Apple platforms with support for CloudKit. */ diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt index 3eccd9eb7..92d27583c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqliteAppDb.kt @@ -3,11 +3,14 @@ package fr.acinq.phoenix.db import app.cash.sqldelight.EnumColumnAdapter import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.db.SqlDriver +import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.data.BoltCardInfo import fr.acinq.phoenix.data.ExchangeRate import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.data.Notification +import fr.acinq.phoenix.db.cards.BoltCardQueries import fr.acinq.phoenix.db.notifications.NotificationsQueries import fr.acinq.phoenix.db.sqldelight.AppDatabase import fr.acinq.phoenix.db.sqldelight.Exchange_rates @@ -17,7 +20,11 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -class SqliteAppDb(val driver: SqlDriver) { +class SqliteAppDb( + loggerFactory: LoggerFactory, + val driver: SqlDriver +) { + private val log = loggerFactory.newLogger(this::class) internal val database = AppDatabase( driver = driver, @@ -121,28 +128,55 @@ class SqliteAppDb(val driver: SqlDriver) { } suspend fun getValue(key: String): Pair? { - return keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { - Pair(it.value_, it.updated_at) + return withContext(Dispatchers.Default) { + keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { + Pair(it.value_, it.updated_at) + } } } suspend fun getValue(key: String, transform: (ByteArray) -> T): Pair? { - return keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { - val tValue = transform(it.value_) - Pair(tValue, it.updated_at) + return withContext(Dispatchers.Default) { + keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { + val tValue = transform(it.value_) + Pair(tValue, it.updated_at) + } } } suspend fun setValue(value: ByteArray, key: String): Long { - return database.transactionWithResult { - val exists = keyValueStoreQueries.exists(key).executeAsOne() > 0 - val now = currentTimestampMillis() - if (exists) { - keyValueStoreQueries.update(key = key, value_ = value, updated_at = now) - } else { - keyValueStoreQueries.insert(key = key, value_ = value, updated_at = now) + return withContext(Dispatchers.Default) { + database.transactionWithResult { + val exists = keyValueStoreQueries.exists(key).executeAsOne() > 0 + val now = currentTimestampMillis() + if (exists) { + keyValueStoreQueries.update(key = key, value_ = value, updated_at = now) + } else { + keyValueStoreQueries.insert(key = key, value_ = value, updated_at = now) + } + now + } + } + } + + suspend fun setValueIfUnchanged(value: ByteArray, key: String, lastUpdated: Long?): Long? { + return withContext(Dispatchers.Default) { + database.transactionWithResult { + val updated = keyValueStoreQueries.get(key).executeAsOneOrNull()?.let { + it.updated_at + } + if (updated == lastUpdated) { + val now = currentTimestampMillis() + if (updated != null) { + keyValueStoreQueries.update(key = key, value_ = value, updated_at = now) + } else { + keyValueStoreQueries.insert(key = key, value_ = value, updated_at = now) + } + now + } else { + null + } } - now } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt index ba49d437a..df933ba10 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/SqlitePaymentsDb.kt @@ -28,6 +28,7 @@ import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.phoenix.data.ContactAddress import fr.acinq.phoenix.data.WalletPaymentInfo import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.db.cards.SqliteCardsDb import fr.acinq.phoenix.db.contacts.SqliteContactsDb import fr.acinq.phoenix.db.payments.* import fr.acinq.phoenix.db.payments.PaymentsMetadataQueries @@ -53,6 +54,7 @@ class SqlitePaymentsDb( val metadataQueries = PaymentsMetadataQueries(database) val contacts = SqliteContactsDb(driver, database, loggerFactory) + val cards = SqliteCardsDb(driver, database, loggerFactory) val log = loggerFactory.newLogger(SqlitePaymentsDb::class) @@ -191,9 +193,9 @@ class SqlitePaymentsDb( modified_at: Long?, original_fiat_type: String?, original_fiat_rate: Double?, - lightning_address: String? + lightning_address: String?, + card_id: String? ): WalletPaymentInfo { - val payment = try { WalletPaymentAdapter.decode(data_) } catch (e: Exception) { @@ -215,12 +217,55 @@ class SqlitePaymentsDb( modified_at = modified_at, original_fiat_type = original_fiat_type, original_fiat_rate = original_fiat_rate, - lightning_address = lightning_address + lightning_address = lightning_address, + card_id = card_id ) return WalletPaymentInfo(payment, metadata, null) } + @Suppress("UNUSED_PARAMETER") + private fun mapOutgoingPaymentsAndMetadata( + data_: OutgoingPayment, + payment_id: UUID?, + lnurl_base_type: LnurlBase.TypeVersion?, + lnurl_base_blob: ByteArray?, + lnurl_description: String?, + lnurl_metadata_type: LnurlMetadata.TypeVersion?, + lnurl_metadata_blob: ByteArray?, + lnurl_successAction_type: LnurlSuccessAction.TypeVersion?, + lnurl_successAction_blob: ByteArray?, + user_description: String?, + user_notes: String?, + modified_at: Long?, + original_fiat_type: String?, + original_fiat_rate: Double?, + lightning_address: String?, + card_id: String? + ): WalletPaymentInfo { + return WalletPaymentInfo( + payment = data_, + metadata = PaymentsMetadataQueries.mapAll( + id = data_.id, + lnurl_base_type = lnurl_base_type, + lnurl_base_blob = lnurl_base_blob, + lnurl_description = lnurl_description, + lnurl_metadata_type = lnurl_metadata_type, + lnurl_metadata_blob = lnurl_metadata_blob, + lnurl_successAction_type = lnurl_successAction_type, + lnurl_successAction_blob = lnurl_successAction_blob, + user_description = user_description, + user_notes = user_notes, + modified_at = modified_at, + original_fiat_type = original_fiat_type, + original_fiat_rate = original_fiat_rate, + lightning_address = lightning_address, + card_id = card_id + ), + contact = null + ) + } + suspend fun getOldestCompletedDate(): Long? = withContext(Dispatchers.Default) { database.paymentsQueries.getOldestCompletedAt().executeAsOneOrNull()?.completed_at } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cards/BoltCardQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cards/BoltCardQueries.kt new file mode 100644 index 000000000..780ca67ef --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cards/BoltCardQueries.kt @@ -0,0 +1,154 @@ +package fr.acinq.phoenix.db.cards + +import app.cash.sqldelight.coroutines.asFlow +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.phoenix.data.BitcoinUnit +import fr.acinq.phoenix.data.BoltCardInfo +import fr.acinq.phoenix.data.BoltCardKeySet +import fr.acinq.phoenix.data.CurrencyUnit +import fr.acinq.phoenix.data.FiatCurrency +import fr.acinq.phoenix.data.SpendingLimit +import fr.acinq.phoenix.db.didDeleteCard +import fr.acinq.phoenix.db.didSaveCard +import fr.acinq.phoenix.db.sqldelight.Bolt_cards +import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +class BoltCardQueries(val database: PaymentsDatabase) { + + val queries = database.boltCardsQueries + + fun saveCard(card: BoltCardInfo, notify: Boolean = true) { + database.transaction { + val cardExists = queries.existsCard( + cardId = card.id.toString() + ).executeAsOne() > 0 + if (cardExists) { + updateExistingCard(card) + } else { + saveNewCard(card) + } + if (notify) { + didSaveCard(card.id, database) + } + } + } + + private fun saveNewCard(card: BoltCardInfo) { + queries.insertCard( + id = card.id.toString(), + name = card.name, + key0 = card.keys.key0.toByteArray(), + uid = card.uid.toByteArray(), + counter = card.lastKnownCounter.toLong(), + isFrozen = card.isFrozen, + isArchived = card.isArchived, + isReset = card.isReset, + isForeign = card.isForeign, + dailyLimitCurrency = card.dailyLimit?.currency?.displayCode, + dailyLimitAmount = card.dailyLimit?.amount, + monthlyLimitCurrency = card.monthlyLimit?.currency?.displayCode, + monthlyLimitAmount = card.monthlyLimit?.amount, + createdAt = card.createdAt.toEpochMilliseconds(), + updatedAt = null + ) + } + + fun updateExistingCard(card: BoltCardInfo) { + queries.updateCard( + name = card.name, + counter = card.lastKnownCounter.toLong(), + isFrozen = card.isFrozen, + isArchived = card.isArchived, + isReset = card.isReset, + dailyLimitCurrency = card.dailyLimit?.currency?.displayCode, + dailyLimitAmount = card.dailyLimit?.amount, + monthlyLimitCurrency = card.monthlyLimit?.currency?.displayCode, + monthlyLimitAmount = card.monthlyLimit?.amount, + updatedAt = currentTimestampMillis(), + cardId = card.id.toString() + ) + } + + fun listCards(): List { + return database.transactionWithResult { + queries.listCards().executeAsList().mapNotNull { row -> + parseRow(row) + } + } + } + + fun monitorCardsFlow(context: CoroutineContext): Flow> { + return queries.listCards().asFlow().map { + withContext(context) { + listCards() + } + } + } + + fun getCard(cardId: UUID): BoltCardInfo? { + return database.transactionWithResult { + queries.getCard( + cardId = cardId.toString() + ).executeAsOneOrNull()?.let { row -> + parseRow(row) + } + } + } + + fun deleteCard(cardId: UUID, notify: Boolean = true) { + return database.transaction { + queries.deleteCard(cardId = cardId.toString()) + if (notify) { + didDeleteCard(cardId, database) + } + } + } + + private fun parseRow(row: Bolt_cards): BoltCardInfo? { + val id: UUID + val keys: BoltCardKeySet + try { // these can throw exceptions if input is incorrect length + id = UUID.Companion.fromString(row.id) + keys = BoltCardKeySet(key0 = row.key0.toByteVector()) + } catch (_: Exception) { + return null + } + + return BoltCardInfo( + id = id, + name = row.name, + keys = keys, + uid = row.uid.toByteVector(), + lastKnownCounter = row.counter.toUInt(), + isFrozen = row.is_frozen, + isArchived = row.is_archived, + isReset = row.is_reset, + isForeign = row.is_foreign, + dailyLimit = parseSpendingLimit(row.daily_limit_currency, row.daily_limit_amount), + monthlyLimit = parseSpendingLimit(row.monthly_limit_currency, row.monthly_limit_amount), + createdAt = Instant.Companion.fromEpochMilliseconds(row.created_at) + ) + } + + private fun parseSpendingLimit(currency: String?, amount: Double?): SpendingLimit? { + if (currency != null && amount != null) { + val parsedCurrency: CurrencyUnit? = + FiatCurrency.Companion.valueOfOrNull(currency) ?: + BitcoinUnit.Companion.valueOfOrNull(currency) + + if (parsedCurrency != null && amount > 0) { + return SpendingLimit(parsedCurrency, amount) + } + } + return null + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cards/SqliteCardsDb.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cards/SqliteCardsDb.kt new file mode 100644 index 000000000..2b1a1f346 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cards/SqliteCardsDb.kt @@ -0,0 +1,322 @@ +package fr.acinq.phoenix.db.cards + +import app.cash.sqldelight.db.SqlDriver +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.db.OutgoingPayment +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.utils.UUID +import fr.acinq.phoenix.data.BoltCardInfo +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import fr.acinq.phoenix.data.WalletPaymentInfo +import fr.acinq.phoenix.db.payments.LnurlBase +import fr.acinq.phoenix.db.payments.LnurlMetadata +import fr.acinq.phoenix.db.payments.LnurlSuccessAction +import fr.acinq.phoenix.db.payments.PaymentsMetadataQueries +import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase +import fr.acinq.phoenix.managers.global.CurrencyManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +class SqliteCardsDb( + val driver: SqlDriver, + val database: PaymentsDatabase, + val loggerFactory: LoggerFactory +): CoroutineScope by MainScope() { + + private val log = loggerFactory.newLogger(this::class) + + val cardQueries = BoltCardQueries(database) + + private val _cardsList = MutableStateFlow>(emptyList()) + val cardsList = _cardsList.asStateFlow() + + private val _cardsMap = MutableStateFlow>(emptyMap()) + val cardsMap = _cardsMap.asStateFlow() + + init { + launch { + cardQueries.monitorCardsFlow(Dispatchers.Default).collect { list -> + val newMap = list.associateBy { it.id } + _cardsList.value = list + _cardsMap.value = newMap + } + } + } + + suspend fun listCards(): List = withContext(Dispatchers.Default) { + cardQueries.listCards() + } + + /** + * Saves a new card, or updates an existing card. + */ + suspend fun saveCard(card: BoltCardInfo) = withContext(Dispatchers.Default) { + cardQueries.saveCard(card) + } + + suspend fun deleteCard(cardId: UUID) = withContext(Dispatchers.Default) { + cardQueries.deleteCard(cardId) + } + + fun cardForId(cardId: UUID): BoltCardInfo? { + return cardsMap.value[cardId] + } + + data class CardPayments( + val daily: List, + val monthly: List + ) { + companion object { + fun fromMonthly(monthly: List, startOfDay: Long): CardPayments { + val daily = monthly.filter { (it.payment.completedAt ?: it.payment.createdAt) > startOfDay } + return CardPayments(monthly = monthly, daily = daily) + } + } + } + + suspend fun fetchCardPayments(cardId: UUID): CardPayments { + val nowInstant = Clock.System.now() + val timezone = TimeZone.currentSystemDefault() + val nowLDT = nowInstant.toLocalDateTime(timezone) + + val startOfMonth = LocalDateTime( + year = nowLDT.year, month = nowLDT.month, day = 1, + hour = 0, minute = 0, second = 0, nanosecond = 0 + ) + + val monthly = fetchRecentCardPayments( + cardId = cardId, + minInstant = startOfMonth.toInstant(timezone) + ) + + val startOfDay = LocalDateTime( + date = nowLDT.date, + time = LocalTime(hour = 0, minute = 0) + ) + val startOfDayMillis = startOfDay.toInstant(timezone).toEpochMilliseconds() + + return CardPayments.fromMonthly( + monthly = monthly, + startOfDay = startOfDayMillis + ) + } + + private suspend fun fetchRecentCardPayments( + cardId: UUID, + minInstant: Instant + ): List = withContext(Dispatchers.Default) { + + var done = false + val maxBatchCount = 50 + var offset = 0 + val results = mutableListOf() + do { + val batch = database.paymentsOutgoingQueries.listRecentCardPayments( + card_id = cardId.toString(), + min_ts = minInstant.toEpochMilliseconds(), + limit = maxBatchCount.toLong(), + offset = offset.toLong(), + mapper = ::mapOutgoingPaymentsAndMetadata + ).executeAsList() + results.addAll(batch) + if (batch.size >= maxBatchCount) { + offset += batch.size + } else { + done = true + } + } while (!done) + + results + } + + data class CardAmounts( + val daily: List, + val monthly: List + ) { + data class Info( + val paymentAmount: MilliSatoshi, + val originalFiat: ExchangeRate.BitcoinPriceRate? + ) + + fun dailyBitcoinAmount() = MilliSatoshi(msat = daily.sumOf { it.paymentAmount.msat }) + fun monthlyBitcoinAmount() = MilliSatoshi(msat = monthly.sumOf { it.paymentAmount.msat }) + + fun dailyFiatAmount( + target: FiatCurrency, + exchangeRates: List + ): Double { + return calculateFiatAmount(daily, target, exchangeRates) + } + + fun monthlyFiatAmount( + target: FiatCurrency, + exchangeRates: List + ): Double { + return calculateFiatAmount(monthly, target, exchangeRates) + } + + companion object { + fun calculateFiatAmount( + list: List, + targetFiatCurrency: FiatCurrency, + exchangeRates: List + ): Double { + var totalAmt = 0.0 + val currentDstRate = CurrencyManager.exchangeRate(targetFiatCurrency, exchangeRates) + + list.forEach { row -> + var rowAmt = 0.0 + row.originalFiat?.let { originalFiat -> + // For this payment, we stored the original fiat value. + // (Note that this should ALWAYS be the case.) + // + // We want to use this original value because + // it makes the most sense to the user. + + if (originalFiat.fiatCurrency == targetFiatCurrency) { + // This is the common case. + // For example: + // - the user's preferred fiatCurrency is set to EUR + // - thus the stored originalFiat rates are in EUR + // - and their daily/monthly amounts are also in EUR + + rowAmt = CurrencyManager.convertToFiat(row.paymentAmount, originalFiat) + } else { + // This is the uncommon case. + // E.g. + // - the stored originalFiat rate is in USD + // - but their daily/monthly amounts are in EUR + // + // To deal with this situation we're going to use the current exchange + // rates between USD & EUR to calculate the (approximate) original + // amount in EUR. + // + // For example: + // - paymentAmount = 0.1 BTC + // - originalFiat = BitcoinPriceRate(USD, 60_000) + // - rates = List< + // BitcoinPriceRate(USD, 100_000), + // BitcoinPriceRate(EUR, 94_738) + // > + // + // originalFiatAmount = 0.1 * 60_000 => 6_000 USD + // percent = 94_738 / 100_000 = 0.94738 + // estimatedFiatAmount = 6_000 * 0.94738 = 5_684 EUR + + val originalFiatAmount = + CurrencyManager.convertToFiat(row.paymentAmount, originalFiat) + + val currentSrcRate = CurrencyManager.exchangeRate( + originalFiat.fiatCurrency, + exchangeRates + ) + if (currentSrcRate != null && currentDstRate != null) { + val percent = currentDstRate.price / currentSrcRate.price + val estimatedFiatAmount = originalFiatAmount * percent + + rowAmt = estimatedFiatAmount + } + } + } + + if (rowAmt == 0.0) { + // We were unable to calculate `amt` using the `originalFiat` value. + // So we'll have to do it using the current exchange rates. + if (currentDstRate != null) { + rowAmt = CurrencyManager.convertToFiat(row.paymentAmount, currentDstRate) + } + } + + totalAmt += rowAmt + } + + return totalAmt + } + } + } + + fun getCardAmounts( + payments: CardPayments + ): CardAmounts { + + val dailyPaymentIds = payments.daily.map { it.id } + + val daily: MutableList = mutableListOf() + val monthly: MutableList = mutableListOf() + payments.monthly.forEach { row -> + val info = CardAmounts.Info( + paymentAmount = row.payment.amount, + originalFiat = row.metadata.originalFiat + ) + monthly.add(info) + if (dailyPaymentIds.contains(row.id)) { + daily.add(info) + } + } + + return CardAmounts( + daily = daily.toList(), + monthly = monthly.toList() + ) + } + + companion object { + + @Suppress("UNUSED_PARAMETER") + private fun mapOutgoingPaymentsAndMetadata( + data_: OutgoingPayment, + payment_id: UUID?, + lnurl_base_type: LnurlBase.TypeVersion?, + lnurl_base_blob: ByteArray?, + lnurl_description: String?, + lnurl_metadata_type: LnurlMetadata.TypeVersion?, + lnurl_metadata_blob: ByteArray?, + lnurl_successAction_type: LnurlSuccessAction.TypeVersion?, + lnurl_successAction_blob: ByteArray?, + user_description: String?, + user_notes: String?, + modified_at: Long?, + original_fiat_type: String?, + original_fiat_rate: Double?, + lightning_address: String?, + card_id: String? + ): WalletPaymentInfo { + return WalletPaymentInfo( + payment = data_, + metadata = PaymentsMetadataQueries.mapAll( + id = data_.id, + lnurl_base_type = lnurl_base_type, + lnurl_base_blob = lnurl_base_blob, + lnurl_description = lnurl_description, + lnurl_metadata_type = lnurl_metadata_type, + lnurl_metadata_blob = lnurl_metadata_blob, + lnurl_successAction_type = lnurl_successAction_type, + lnurl_successAction_blob = lnurl_successAction_blob, + user_description = user_description, + user_notes = user_notes, + modified_at = modified_at, + original_fiat_type = original_fiat_type, + original_fiat_rate = original_fiat_rate, + lightning_address = lightning_address, + card_id = card_id + ), + contact = null + ) + } + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/cards/CloudCard.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/cards/CloudCard.kt new file mode 100644 index 000000000..96f83b23e --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/cloud/cards/CloudCard.kt @@ -0,0 +1,135 @@ +package fr.acinq.phoenix.db.cloud.cards + +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.phoenix.data.BitcoinUnit +import fr.acinq.phoenix.data.BoltCardInfo +import fr.acinq.phoenix.data.BoltCardKeySet +import fr.acinq.phoenix.data.FiatCurrency +import fr.acinq.phoenix.data.SpendingLimit +import fr.acinq.phoenix.db.cloud.UUIDSerializer +import fr.acinq.phoenix.db.cloud.cborSerializer +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ByteString +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.json.Json +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +sealed class CloudCard { + + enum class Version(val value: Int) { + // Initial version + V0(0) + // Future versions go here + } + + @Serializable + @OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class) + data class V0( + @SerialName("v") + val version: Int, + @Serializable(with = UUIDSerializer::class) + val id: UUID, + val name: String, + @ByteString val key0: ByteArray, + @ByteString val uid: ByteArray, + val lastKnownCounter: UInt, + val isFrozen: Boolean, + val isArchived: Boolean, + val isReset: Boolean, + val isForeign: Boolean, + val dailyLimit: SpendingLimitWrapper?, + val monthlyLimit: SpendingLimitWrapper?, + val createdAt: Long + ): CloudCard() { + + constructor(card: BoltCardInfo) : this( + version = Version.V0.value, + id = card.id, + name = card.name, + key0 = card.keys.key0.toByteArray(), + uid = card.uid.toByteArray(), + lastKnownCounter = card.lastKnownCounter, + isFrozen = card.isFrozen, + isArchived = card.isArchived, + isReset = card.isReset, + isForeign = card.isForeign, + dailyLimit = card.dailyLimit?.let { SpendingLimitWrapper(it) }, + monthlyLimit = card.monthlyLimit?.let { SpendingLimitWrapper(it) }, + createdAt = card.createdAt.toEpochMilliseconds() + ) + + @OptIn(ExperimentalSerializationApi::class) + fun cborSerialize(): ByteArray { + return Cbor.encodeToByteArray(this) + } + + /** + * For DEBUGGING: + * + * You can use the jsonSerializer to see what the data looks like. + * Just keep in mind that the ByteArray's will be encoded super-inefficiently. + * That's because we're optimizing for Cbor. + * To optimize for JSON, you would use ByteVector's, + * and encode the data as Base64 via ByteVectorJsonSerializer. + */ + fun jsonSerialize(): ByteArray { + return Json.encodeToString(this).encodeToByteArray() + } + + @Throws(Exception::class) + fun unwrap(): BoltCardInfo { + val keys = BoltCardKeySet(key0 = this.key0.toByteVector()) + return BoltCardInfo( + id = this.id, + name = this.name, + keys = keys, + uid = this.uid.toByteVector(), + lastKnownCounter = this.lastKnownCounter, + isFrozen = this.isFrozen, + isArchived = this.isArchived, + isReset = this.isReset, + isForeign = this.isForeign, + dailyLimit = this.dailyLimit?.unwrap(), + monthlyLimit = this.monthlyLimit?.unwrap(), + createdAt = Instant.fromEpochMilliseconds(this.createdAt) + ) + } + + companion object + + @Serializable + data class SpendingLimitWrapper( + val currency: String, + val amount: Double + ) { + constructor(limit: SpendingLimit) : this( + currency = limit.currency.displayCode, + amount = limit.amount + ) + + fun unwrap(): SpendingLimit? { + val currency = FiatCurrency.valueOfOrNull(currency) ?: BitcoinUnit.valueOfOrNull(this.currency) + return currency?.let { + SpendingLimit(currency, this.amount) + } + } + } + } + + companion object { + + @OptIn(ExperimentalSerializationApi::class) + @Throws(Exception::class) + fun cborDeserializeAndUnwrap( + blob: ByteArray + ): BoltCardInfo? { + return cborSerializer().decodeFromByteArray(blob).unwrap() + } + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt index 436a4a8d4..bbf57da9b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataTypes.kt @@ -2,6 +2,7 @@ package fr.acinq.phoenix.db.payments import fr.acinq.bitcoin.ByteVector import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.utils.UUID import fr.acinq.phoenix.db.cloud.cborSerializer import fr.acinq.phoenix.data.* import fr.acinq.phoenix.data.lnurl.LnurlPay @@ -224,6 +225,7 @@ data class WalletPaymentMetadataRow( val user_description: String? = null, val user_notes: String? = null, val lightning_address: String? = null, + val card_id: String? = null, val modified_at: Long? = null ) { @@ -267,12 +269,19 @@ data class WalletPaymentMetadataRow( } } + val cardId = card_id?.let { + try { + UUID.fromString(card_id) + } catch (e: Exception) { null } + } + return WalletPaymentMetadata( lnurl = lnurl, originalFiat = originalFiat, userDescription = user_description, userNotes = user_notes, lightningAddress = lightning_address, + cardId = cardId, modifiedAt = modified_at ) } @@ -289,6 +298,7 @@ data class WalletPaymentMetadataRow( && user_description == null && user_notes == null && lightning_address == null + && card_id == null } } @@ -317,6 +327,7 @@ fun WalletPaymentMetadata.serialize(): WalletPaymentMetadataRow? { user_description = userDescription, user_notes = userNotes, lightning_address = lightningAddress, + card_id = cardId?.toString(), modified_at = modifiedAt ) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/PaymentsMetadataQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/PaymentsMetadataQueries.kt index b477a7b75..45be08ebc 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/PaymentsMetadataQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/PaymentsMetadataQueries.kt @@ -31,7 +31,8 @@ class PaymentsMetadataQueries(val database: PaymentsDatabase) { modified_at = data.modified_at, original_fiat_type = data.original_fiat?.first, original_fiat_rate = data.original_fiat?.second, - lightning_address = data.lightning_address + lightning_address = data.lightning_address, + card_id = data.card_id ) didUpdateWalletPaymentMetadata(id, database) } @@ -70,7 +71,8 @@ class PaymentsMetadataQueries(val database: PaymentsDatabase) { modified_at = modifiedAt, original_fiat_type = null, original_fiat_rate = null, - lightning_address = null + lightning_address = null, + card_id = null ) } didUpdateWalletPaymentMetadata(id, database) @@ -94,7 +96,8 @@ class PaymentsMetadataQueries(val database: PaymentsDatabase) { modified_at: Long?, original_fiat_type: String?, original_fiat_rate: Double?, - lightning_address: String? + lightning_address: String?, + card_id: String? ): WalletPaymentMetadata { val lnurlBase = if (lnurl_base_type != null && lnurl_base_blob != null) { @@ -125,6 +128,7 @@ class PaymentsMetadataQueries(val database: PaymentsDatabase) { user_description = user_description, user_notes = user_notes, lightning_address = lightning_address, + card_id = card_id, modified_at = modified_at ).deserialize() } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt index 1a135ff8c..e04a6b736 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/DatabaseManager.kt @@ -10,6 +10,7 @@ import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.db.SqliteAppDb import fr.acinq.phoenix.db.SqliteChannelsDb import fr.acinq.phoenix.db.SqlitePaymentsDb +import fr.acinq.phoenix.db.cards.SqliteCardsDb import fr.acinq.phoenix.db.contacts.SqliteContactsDb import fr.acinq.phoenix.db.createChannelsDbDriver import fr.acinq.phoenix.db.createPaymentsDbDriver @@ -97,19 +98,11 @@ class DatabaseManager( } } - suspend fun paymentsDb(): SqlitePaymentsDb { - val db = databases.filterNotNull().first() - return db.payments - } - - suspend fun contactsDb(): SqliteContactsDb { - return paymentsDb().contacts - } + suspend fun paymentsDb(): SqlitePaymentsDb = databases.filterNotNull().first().payments + suspend fun cardsDb(): SqliteCardsDb = paymentsDb().cards + suspend fun contactsDb(): SqliteContactsDb = paymentsDb().contacts - suspend fun cloudKitDb(): CloudKitInterface? { - val db = databases.filterNotNull().first() - return db.cloudKit - } + suspend fun cloudKitDb(): CloudKitInterface? = databases.filterNotNull().first().cloudKit companion object { fun channelsDbName(chain: Chain, nodeId: PublicKey): String { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt index 277c02d90..ee48651b7 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/SendManager.kt @@ -1,6 +1,7 @@ package fr.acinq.phoenix.managers import fr.acinq.bitcoin.BitcoinError +import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.utils.Either @@ -12,10 +13,12 @@ import fr.acinq.lightning.io.OfferInvoiceReceived import fr.acinq.lightning.io.OfferNotPaid import fr.acinq.lightning.io.PayInvoice import fr.acinq.lightning.io.PayOffer +import fr.acinq.lightning.io.SendPaymentResult import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.logging.debug import fr.acinq.lightning.logging.error import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.payment.Bolt12Invoice import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.wire.OfferTypes @@ -205,12 +208,12 @@ class SendManager( ) } - private suspend fun checkForBadBolt11Invoice( + suspend fun checkForBadBolt11Invoice( invoice: Bolt11Invoice ): BadRequestReason? { - val actualChain = invoice.chain - if (chain != actualChain) { + val invoiceChain = invoice.chain + if (chain != invoiceChain) { return BadRequestReason.ChainMismatch(expected = chain) } @@ -218,8 +221,28 @@ class SendManager( return BadRequestReason.Expired(invoice.timestampSeconds, invoice.expirySeconds ?: Bolt11Invoice.DEFAULT_EXPIRY_SECONDS.toLong()) } + return checkPaymentHash(invoice.paymentHash) + } + + suspend fun checkForBadBolt12Invoice( + invoice: Bolt12Invoice + ): BadRequestReason? { + + val invoiceChainHash = invoice.chain + if (chain.chainHash != invoiceChainHash) { + return BadRequestReason.ChainMismatch(expected = chain) + } + + if (invoice.isExpired(currentTimestampSeconds())) { + return BadRequestReason.Expired(invoice.createdAtSeconds, invoice.relativeExpirySeconds) + } + + return checkPaymentHash(invoice.paymentHash) + } + + private suspend fun checkPaymentHash(paymentHash: ByteVector32): BadRequestReason? { val db = databaseManager.databases.filterNotNull().first() - val similarPayments = db.payments.listLightningOutgoingPayments(invoice.paymentHash) + val similarPayments = db.payments.listLightningOutgoingPayments(paymentHash) // we MUST raise an error if this payment hash has already been paid, or is being paid. // parallel pending payments on the same payment hash can trigger force-closes // FIXME: this check should be done in lightning-kmp, not in Phoenix @@ -510,6 +533,21 @@ class SendManager( )) return res.await() } + + suspend fun payUnsolicitedInvoice( + invoice: Bolt12Invoice, + metadata: WalletPaymentMetadata? + ): SendPaymentResult { + val paymentId: UUID = UUID.randomUUID() + val peer = peerManager.getPeer() + + // save card metadata if any + metadata?.let { row -> + databaseManager.paymentMetadataQueue.enqueue(row = row, id = paymentId) + } + + return peer.payUnsolicitedInvoice(invoice, paymentId) + } /** * Step 1 of 2: diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt index e0656b7a2..28ad8a0a6 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt @@ -16,6 +16,7 @@ package fr.acinq.phoenix.managers.global +import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.logging.debug import fr.acinq.lightning.logging.info @@ -223,31 +224,7 @@ class CurrencyManager( */ fun calculateOriginalFiat(currency: FiatCurrency): ExchangeRate.BitcoinPriceRate? { val rates = ratesFlow.value - val fiatRate = rates.firstOrNull { it.fiatCurrency == currency } ?: return null - - return when (fiatRate) { - is ExchangeRate.BitcoinPriceRate -> { - // We have a direct exchange rate. - // BitcoinPriceRate.rate => The price of 1 BTC in this currency - fiatRate - } - is ExchangeRate.UsdPriceRate -> { - // We have an indirect exchange rate. - // UsdPriceRate.price => The price of 1 US Dollar in this currency - rates.filterIsInstance().firstOrNull { - it.fiatCurrency == FiatCurrency.USD - }?.let { usdRate -> - ExchangeRate.BitcoinPriceRate( - fiatCurrency = currency, - price = usdRate.price * fiatRate.price, - source = "${fiatRate.source}/${usdRate.source}", - timestampMillis = fiatRate.timestampMillis.coerceAtMost( - usdRate.timestampMillis - ) - ) - } - } - } + return exchangeRate(currency, rates) } /** @@ -421,4 +398,81 @@ class CurrencyManager( ) } } + + companion object { + + /** + * Exchange rates can be confusing. + * - BitcoinPriceRate: converts between BTC and fiat + * - UsdPriceRate: converts between USD and fiat + * + * This function takes a list of exchange rates, + * and returns a standardized BitcoinPriceRate, + * which is easier to work with. + */ + fun exchangeRate( + fiatCurrency: FiatCurrency, + rates: List + ): ExchangeRate.BitcoinPriceRate? { + + val fiatRate = rates.firstOrNull { it.fiatCurrency == fiatCurrency } ?: return null + + return when (fiatRate) { + is ExchangeRate.BitcoinPriceRate -> { + // We have a direct exchange rate. + // BitcoinPriceRate.rate => The price of 1 BTC in this currency + fiatRate + } + is ExchangeRate.UsdPriceRate -> { + // We have an indirect exchange rate. + // UsdPriceRate.price => The price of 1 US Dollar in this currency + rates.filterIsInstance().firstOrNull { + it.fiatCurrency == FiatCurrency.USD + }?.let { usdRate -> + ExchangeRate.BitcoinPriceRate( + fiatCurrency = fiatCurrency, + price = usdRate.price * fiatRate.price, + source = "${fiatRate.source}/${usdRate.source}", + timestampMillis = fiatRate.timestampMillis.coerceAtMost( + usdRate.timestampMillis + ) + ) + } + } + } + } + + /** + * Converts the given amount (in MilliSatoshi) into a fiat value. + */ + fun convertToFiat( + msat: MilliSatoshi, + exchangeRate: ExchangeRate.BitcoinPriceRate + ): Double { + + // exchangeRate.price => value of 1.0 BTC in fiat + // data class MilliSatoshi(val msat: Long) + + val btc = msat.toLong().toDouble() / 100_000_000_000.0 + val fiat = btc * exchangeRate.price + + return fiat + } + + /** + * Converts the given amount (in fiat) into a bitcoin amount. + */ + fun convertToMsat( + fiatAmount: Double, + exchangeRate: ExchangeRate.BitcoinPriceRate + ): MilliSatoshi { + + // exchangeRate.price => value of 1.0 BTC in fiat + + val btc: Double = fiatAmount / exchangeRate.price + val msat = (btc * 100_000_000_000.0).toLong() + + return MilliSatoshi(msat) + } + } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/BoltCards.sq b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/BoltCards.sq new file mode 100644 index 000000000..e77f15ae8 --- /dev/null +++ b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/BoltCards.sq @@ -0,0 +1,66 @@ +import kotlin.Boolean; + +CREATE TABLE IF NOT EXISTS bolt_cards ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + key0 BLOB NOT NULL, + uid BLOB NOT NULL, + counter INTEGER NOT NULL, + is_frozen INTEGER AS Boolean NOT NULL, + is_archived INTEGER AS Boolean NOT NULL, + is_reset INTEGER AS Boolean NOT NULL, + is_foreign INTEGER AS Boolean NOT NULL, + daily_limit_currency TEXT, + daily_limit_amount REAL, + monthly_limit_currency TEXT, + monthly_limit_amount REAL, + created_at INTEGER NOT NULL, + updated_at INTEGER DEFAULT NULL +); + +listCards: +SELECT * FROM bolt_cards +ORDER BY created_at DESC; + +scanCards: +SELECT id, created_at FROM bolt_cards; + +existsCard: +SELECT COUNT(*) FROM bolt_cards +WHERE id = :cardId; + +getCard: +SELECT * FROM bolt_cards +WHERE id = :cardId; + +insertCard: +INSERT INTO bolt_cards( + id, name, key0, uid, counter, + is_frozen, is_archived, is_reset, is_foreign, + daily_limit_currency, daily_limit_amount, + monthly_limit_currency, monthly_limit_amount, + created_at, updated_at +) VALUES ( + :id, :name, :key0, :uid, :counter, + :isFrozen, :isArchived, :isReset, :isForeign, + :dailyLimitCurrency, :dailyLimitAmount, + :monthlyLimitCurrency, :monthlyLimitAmount, + :createdAt, :updatedAt +); + +updateCard: +UPDATE bolt_cards +SET name=:name, + counter=:counter, + is_frozen=:isFrozen, + is_archived=:isArchived, + is_reset=:isReset, + daily_limit_currency=:dailyLimitCurrency, + daily_limit_amount=:dailyLimitAmount, + monthly_limit_currency=:monthlyLimitCurrency, + monthly_limit_amount=:monthlyLimitAmount, + updated_at=:updatedAt +WHERE id=:cardId; + +deleteCard: +DELETE FROM bolt_cards WHERE id=:cardId; \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitCards.sq b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitCards.sq new file mode 100644 index 000000000..3ce8808fe --- /dev/null +++ b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitCards.sq @@ -0,0 +1,83 @@ +-- This table stores the CKRecord metadata corresponding to a synced card. +-- * id => stores the primary key of the card row +-- +CREATE TABLE IF NOT EXISTS cloudkit_cards_metadata ( + id TEXT NOT NULL PRIMARY KEY, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL +); + +-- When resuming the download process (e.g. after app relaunch), +-- we need to fetch the earliest creationDate. +CREATE INDEX IF NOT EXISTS record_creation_idx +ON cloudkit_cards_metadata(record_creation); + +-- This table stores the queue of items that need to be pushed to the cloud. +-- * rowid => because we might store the same `id` multiple times +-- * id => stores the primary key of the card row +-- +CREATE TABLE IF NOT EXISTS cloudkit_cards_queue ( + rowid INTEGER PRIMARY KEY, + id TEXT NOT NULL, + date_added INTEGER NOT NULL +); + +-- ########## cloudkit_cards_metadata ########## + +addMetadata: +INSERT INTO cloudkit_cards_metadata ( + id, + record_creation, + record_blob) +VALUES (?, ?, ?); + +updateMetadata: +UPDATE cloudkit_cards_metadata +SET record_blob = ? +WHERE id = ?; + +existsMetadata: +SELECT COUNT(*) FROM cloudkit_cards_metadata +WHERE id = ?; + +fetchMetadata: +SELECT * FROM cloudkit_cards_metadata +WHERE id = ?; + +scanMetadata: +SELECT id FROM cloudkit_cards_metadata; + +fetchOldestCreation_Cards: +SELECT id, record_creation FROM cloudkit_cards_metadata +ORDER BY record_creation ASC +LIMIT 1; + +deleteMetadata: +DELETE FROM cloudkit_cards_metadata +WHERE id = ?; + +deleteAllFromMetadata: +DELETE FROM cloudkit_cards_metadata; + +-- ########## cloudkit_cards_queue ########## + +addToQueue: +INSERT INTO cloudkit_cards_queue ( + id, + date_added) +VALUES (?, ?); + +fetchQueueBatch: +SELECT * FROM cloudkit_cards_queue +ORDER BY date_added ASC +LIMIT :limit; + +fetchQueueCount: +SELECT COUNT(*) FROM cloudkit_cards_queue; + +deleteFromQueue: +DELETE FROM cloudkit_cards_queue +WHERE rowid = ?; + +deleteAllFromQueue: +DELETE FROM cloudkit_cards_queue; \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitContacts.sq b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitContacts.sq index a395a3203..e8b724451 100644 --- a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitContacts.sq +++ b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/CloudKitContacts.sq @@ -1,5 +1,3 @@ - - -- This table stores the CKRecord metadata corresponding to a synced contact. -- * id => stores the primary key of the contact row -- diff --git a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq index 169e30adf..5a8e5317b 100644 --- a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq +++ b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsMetadata.sq @@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS payments_metadata ( modified_at INTEGER, original_fiat_type TEXT, original_fiat_rate REAL, - lightning_address TEXT + lightning_address TEXT, + card_id TEXT ); -- queries for payments_metadata table @@ -46,8 +47,9 @@ INSERT INTO payments_metadata ( user_description, user_notes, modified_at, original_fiat_type, original_fiat_rate, - lightning_address) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + lightning_address, + card_id) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); updateUserInfo: UPDATE payments_metadata diff --git a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsOutgoing.sq b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsOutgoing.sq index 95fd45fe4..8fa93d99b 100644 --- a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsOutgoing.sq +++ b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/sqldelight/PaymentsOutgoing.sq @@ -97,3 +97,12 @@ listSuccessfulIds: SELECT id, completed_at FROM payments_outgoing WHERE completed_at IS NOT NULL; + +listRecentCardPayments: +SELECT p.data, pm.* +FROM payments_outgoing AS p +LEFT OUTER JOIN payments_metadata AS pm ON pm.payment_id = p.id +WHERE + card_id = :card_id AND created_at >= :min_ts +ORDER BY created_at DESC +LIMIT :limit OFFSET :offset; diff --git a/phoenix-shared/src/commonMain/sqldelight/paymentsdb/migrations/13.sqm b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/migrations/13.sqm new file mode 100644 index 000000000..126831fc8 --- /dev/null +++ b/phoenix-shared/src/commonMain/sqldelight/paymentsdb/migrations/13.sqm @@ -0,0 +1,46 @@ +-- Migration: v13 -> v14 +-- +-- Changes: +-- * Added a new table: bolt_cards +-- * Added a new table: cloudkit_cards_metadata +-- * Added a new index: record_creation_idx on: cloudkit_cards_metadata +-- * Added a new table: cloudkit_cards_queue + +import kotlin.Boolean; + +-- See: BoltCards.sq +CREATE TABLE IF NOT EXISTS bolt_cards ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + key0 BLOB NOT NULL, + uid BLOB NOT NULL, + counter INTEGER NOT NULL, + is_frozen INTEGER AS Boolean NOT NULL, + is_archived INTEGER AS Boolean NOT NULL, + is_reset INTEGER AS Boolean NOT NULL, + is_foreign INTEGER AS Boolean NOT NULL, + daily_limit_currency TEXT, + daily_limit_amount REAL, + monthly_limit_currency TEXT, + monthly_limit_amount REAL, + created_at INTEGER NOT NULL, + updated_at INTEGER DEFAULT NULL +); + +-- See: CloudKitCards.sq +CREATE TABLE IF NOT EXISTS cloudkit_cards_metadata ( + id TEXT NOT NULL PRIMARY KEY, + record_creation INTEGER NOT NULL, + record_blob BLOB NOT NULL +); + +-- See: CloudKitCards.sq +CREATE INDEX IF NOT EXISTS record_creation_idx +ON cloudkit_cards_metadata(record_creation); + +-- See: CloudKitCards.sq +CREATE TABLE IF NOT EXISTS cloudkit_cards_queue ( + rowid INTEGER PRIMARY KEY, + id TEXT NOT NULL, + date_added INTEGER NOT NULL +); \ No newline at end of file diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitCardsDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitCardsDb.kt new file mode 100644 index 000000000..af91622df --- /dev/null +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitCardsDb.kt @@ -0,0 +1,313 @@ +package fr.acinq.phoenix.db + +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.coroutines.asFlow +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampMillis +import fr.acinq.phoenix.data.BoltCardInfo +import kotlin.collections.List +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +class CloudKitCardsDb( + private val paymentsDb: SqlitePaymentsDb +): CoroutineScope by MainScope() { + + private val db: Transacter = paymentsDb.database + private val queries = paymentsDb.database.cloudKitCardsQueries + + /** + * Provides a flow of the count of items within the cloudkit_cards_queue table. + */ + private val _queueCount = MutableStateFlow(0) + val queueCount: StateFlow = _queueCount.asStateFlow() + + data class MetadataRow( + val recordCreation: Long, + val recordBlob: ByteArray + ) + + data class MissingItem( + val cardId: UUID, + val timestamp: Long + ) + + data class FetchQueueBatchResult( + + // The fetched rowid values from the `cloudkit_cards_queue` table + val rowids: List, + + // Maps `cloudkit_cards_queue.rowid` to the corresponding cardId. + // If missing from the map, then the `cloudkit_cards_queue` row was + // malformed or unrecognized. + val rowidMap: Map, + + // Maps to the card information in the database. + // If missing from the map, then the card has been deleted from the database. + val rowMap: Map, + + // Maps to `cloudkit_cards_metadata.ckrecord_info`. + // If missing from the map, then then record doesn't exist in the database. + val metadataMap: Map, + ) + + init { + // N.B.: There appears to be a subtle bug in SQLDelight's + // `.asFlow().mapToX(Dispatchers.Default)`, as described here: + // https://github.com/ACINQ/phoenix/pull/415 + launch { + queries.fetchQueueCount() + .asFlow() + .map { + withContext(Dispatchers.Default) { + db.transactionWithResult { + it.executeAsOne() + } + } + } + .collect { count -> + _queueCount.value = count + } + } + } + + suspend fun fetchQueueBatch(limit: Long): FetchQueueBatchResult { + return withContext(Dispatchers.Default) { + + val rowids = mutableListOf() + val rowidMap = mutableMapOf() + val rowMap = mutableMapOf() + val metadataMap = mutableMapOf() + + val cardQueries = paymentsDb.cards.cardQueries + + db.transaction { + + // Step 1 of 3: + // Fetch the rows from the `cloudkit_cards_queue` batch. + // We are fetching the next/oldest X rows from the queue. + + val batch = queries.fetchQueueBatch(limit).executeAsList() + + // Step 2 of 3: + // Process the batch, and fill out the `rowids` & `rowidMap` variable. + + batch.forEach { row -> + rowids.add(row.rowid) + try { + val cardId = UUID.fromString(row.id) + rowidMap[row.rowid] = cardId + } catch (e: Exception) { + // UUID appears to be malformed within the database. + // Nothing we can do here - but let's at least not crash. + } + } // + + // Remember: there could be duplicates + val uniqueCardIds = rowidMap.values.toSet() + + // Step 3 of 3: + // Fetch the corresponding card info from the database. + + uniqueCardIds.forEach { cardId -> + cardQueries.getCard(cardId)?.let { + rowMap[cardId] = it + } + } + + // Step 4 of 5: + // Fetch the corresponding `cloudkit_cards_metadata.ckrecord_info` + + uniqueCardIds.forEach { cardId -> + queries.fetchMetadata( + id = cardId.toString() + ).executeAsOneOrNull()?.let { row -> + metadataMap[cardId] = row.record_blob + } + } + + } // + + FetchQueueBatchResult( + rowids = rowids, + rowidMap = rowidMap, + rowMap = rowMap, + metadataMap = metadataMap + ) + } + } + + suspend fun updateRows( + deleteFromQueue: List, + deleteFromMetadata: List, + updateMetadata: Map + ) { + withContext(Dispatchers.Default) { + db.transaction { + + deleteFromQueue.forEach { rowid -> + queries.deleteFromQueue(rowid) + } + + deleteFromMetadata.forEach { cardId -> + queries.deleteMetadata( + id = cardId.toString() + ) + } + + updateMetadata.forEach { (cardId, row) -> + val rowExists = queries.existsMetadata( + id = cardId.toString() + ).executeAsOne() > 0 + if (rowExists) { + queries.updateMetadata( + record_blob = row.recordBlob, + id = cardId.toString() + ) + } else { + queries.addMetadata( + id = cardId.toString(), + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } + } + } + } + + suspend fun fetchOldestCreation(): Long? { + return withContext(Dispatchers.Default) { + val row = queries.fetchOldestCreation_Cards().executeAsOneOrNull() + row?.record_creation + } + } + + suspend fun updateRows( + downloadedCards: List, + updateMetadata: Map + ) { + // We are seeing crashes when accessing the values within the List. + // Perhaps because the List was created in Swift ? + // The workaround seems to be to copy the list here, + // or otherwise process it outside of the `withContext` below. + val cards = downloadedCards.map { it.copy() } + + withContext(Dispatchers.Default) { + val cardQueries = paymentsDb.cards.cardQueries + + db.transaction { + for (card in cards) { + + val rowExists = queries.existsMetadata( + id = card.id.toString() + ).executeAsOne() > 0 + if (!rowExists) { + cardQueries.saveCard(card, notify = false) + } + } + + for ((cardId, row) in updateMetadata) { + val rowExists = queries.existsMetadata( + id = cardId.toString() + ).executeAsOne() > 0 + + if (rowExists) { + queries.updateMetadata( + record_blob = row.recordBlob, + id = cardId.toString() + ) + } else { + queries.addMetadata( + id = cardId.toString(), + record_creation = row.recordCreation, + record_blob = row.recordBlob + ) + } + } // + } + } + } + + suspend fun enqueueMissingItems() { + withContext(Dispatchers.Default) { + val rawCardQueries = paymentsDb.database.boltCardsQueries + + db.transaction { + + // Step 1 of 3: + // Fetch list of card ID's that are already represented in the cloud. + + val cloudCardIds = mutableSetOf() + queries.scanMetadata().executeAsList().forEach { id -> + try { + val cardId = UUID.fromString(id) + cloudCardIds.add(cardId) + } catch (e: Exception) { + // UUID appears to be malformed within the database. + // Nothing we can do here - but let's at least not crash. + } + } + + // Step 2 of 3: + // Scan local card ID's, looking to see if any are missing from the cloud. + + val missing = mutableListOf() + rawCardQueries.scanCards().executeAsList().forEach { row -> + try { + val cardId = UUID.fromString(row.id) + if (!cloudCardIds.contains(cardId)) { + missing.add(MissingItem( + cardId = cardId, + timestamp = row.created_at + )) + } + } catch (e: Exception) { + // UUID appears to be malformed within the database. + // Nothing we can do here - but let's at least not crash. + } + } + + // Step 3 of 3: + // Add any missing items to the queue. + // + // But in what order do we want to upload them to the cloud ? + // + // We will choose to upload the OLDEST item first. + // This matches how they normally would have been uploaded. + // Also, when a user restores their wallet (e.g. on a new phone), + // we always want to download the newest items first. + // And this assumes the newest items in the cloud are the newest cards. + // + // Since items are uploaded in FIFO order, + // we just need to make the oldest item have the + // smallest `date_added` value. + + missing.sortByDescending { it.timestamp } + + // The list is now sorted in descending order. + // Which means the newest item is at index 0, + // and the oldest item is at index . + + val now = currentTimestampMillis() + missing.forEachIndexed { idx, item -> + queries.addToQueue( + id = item.cardId.toString(), + date_added = now - idx + ) + } + } + } + } + + suspend fun clearDatabaseTables() { + withContext(Dispatchers.Default) { + db.transaction { + queries.deleteAllFromMetadata() + queries.deleteAllFromQueue() + } + } + } +} diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt index 86f67a4da..a5ec008d4 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitContactsDb.kt @@ -37,13 +37,13 @@ class CloudKitContactsDb( // The fetched rowid values from the `cloudkit_contacts_queue` table val rowids: List, - // Maps `cloudkit_contacts_queue.rowid` to the corresponding ContactId. + // Maps `cloudkit_contacts_queue.rowid` to the corresponding contactId. // If missing from the map, then the `cloudkit_contacts_queue` row was // malformed or unrecognized. val rowidMap: Map, // Maps to the contact information in the database. - // If missing from the map, then the contacts has been deleted from the database. + // If missing from the map, then the contact has been deleted from the database. val rowMap: Map, // Maps to `cloudkit_contacts_metadata.ckrecord_info`. @@ -278,7 +278,7 @@ class CloudKitContactsDb( // We will choose to upload the OLDEST item first. // This matches how they normally would have been uploaded. // Also, when a user restores their wallet (e.g. on a new phone), - // we always want to download the newest contacts first. + // we always want to download the newest items first. // And this assumes the newest items in the cloud are the newest contacts. // // Since items are uploaded in FIFO order, diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt index 5b00bc046..d9df23ef9 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt @@ -8,6 +8,7 @@ class CloudKitDb( paymentsDb: SqlitePaymentsDb ): CloudKitInterface, CoroutineScope by MainScope() { + val cards = CloudKitCardsDb(paymentsDb) val contacts = CloudKitContactsDb(paymentsDb) val payments = CloudKitPaymentsDb(paymentsDb) } diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt index bb5ca697d..251dc2711 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitPaymentsDb.kt @@ -247,7 +247,8 @@ class CloudKitPaymentsDb( modified_at = row.modified_at, original_fiat_type = row.original_fiat?.first, original_fiat_rate = row.original_fiat?.second, - lightning_address = row.lightning_address + lightning_address = row.lightning_address, + card_id = row.card_id ) } } // diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt index d7e8ba5d5..b899e3428 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/SqlPaymentHooks.kt @@ -33,6 +33,20 @@ actual fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) { ) } +actual fun didSaveCard(cardId: UUID, database: PaymentsDatabase) { + database.cloudKitCardsQueries.addToQueue( + id = cardId.toString(), + date_added = currentTimestampMillis() + ) +} + +actual fun didDeleteCard(cardId: UUID, database: PaymentsDatabase) { + database.cloudKitCardsQueries.addToQueue( + id = cardId.toString(), + date_added = currentTimestampMillis() + ) +} + actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? { return CloudKitDb(appDb, paymentsDb) } \ No newline at end of file diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt index 65f0ff65c..93c2905c8 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/LightningExposure.kt @@ -13,6 +13,7 @@ import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.ChannelEvents import fr.acinq.lightning.DefaultSwapInParams import fr.acinq.lightning.Lightning +import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.LiquidityEvents import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.NodeEvents @@ -28,15 +29,21 @@ import fr.acinq.lightning.channel.states.ChannelState import fr.acinq.lightning.channel.states.Closed import fr.acinq.lightning.channel.states.Closing import fr.acinq.lightning.channel.states.Offline +import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.crypto.SwapInOnChainKeys import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.io.NativeSocketException +import fr.acinq.lightning.io.OfferInvoiceReceived +import fr.acinq.lightning.io.OfferNotPaid import fr.acinq.lightning.io.PaymentNotSent import fr.acinq.lightning.io.PaymentProgress import fr.acinq.lightning.io.PaymentSent +import fr.acinq.lightning.io.PayOffer import fr.acinq.lightning.io.Peer +import fr.acinq.lightning.io.Peer.CardPaymentInfo import fr.acinq.lightning.io.PeerEvent import fr.acinq.lightning.io.TcpSocket +import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.payment.FinalFailure import fr.acinq.lightning.payment.LiquidityPolicy import fr.acinq.lightning.payment.OfferManager @@ -48,9 +55,14 @@ import fr.acinq.lightning.utils.toByteArray import fr.acinq.lightning.utils.toNSData import fr.acinq.lightning.wire.LiquidityAds import fr.acinq.lightning.wire.OfferTypes +import kotlinx.coroutines.cancel +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import platform.Foundation.NSData +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * Class types from lightning-kmp & bitcoin-kmp are not exported to iOS unless we explicitly @@ -372,3 +384,56 @@ fun UUID.Companion.tryFromString(string: String): UUID? { null } } + +suspend fun Peer.betterPayOffer( + paymentId: UUID, + amount: MilliSatoshi, + offer: OfferTypes.Offer, + payerKey: PrivateKey, + payerNote: String?, + fetchInvoiceTimeoutInSeconds: Int +): Either { + val res = CompletableDeferred>() + launch { + eventsFlow.collect { + if (it is OfferNotPaid && it.request.paymentId == paymentId) { + res.complete(Either.Left(it)) + cancel() + } else if (it is OfferInvoiceReceived && it.request.paymentId == paymentId) { + res.complete(Either.Right(it)) + cancel() + } + } + } + send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeoutInSeconds.seconds)) + return res.await() +} + +suspend fun Peer._createInvoice( + amount: MilliSatoshi?, + description: String, + expiryInSeconds: Long +): Bolt11Invoice { + return createInvoice( + paymentPreimage = randomBytes32(), + amount = amount, + description = Either.Left(description), + expiry = expiryInSeconds.seconds + ) +} + +suspend fun Peer._requestCardPayment( + amount: MilliSatoshi, + description: String, + timeoutInSeconds: Long, + cardHolderOffer: OfferTypes.Offer, + cardParams: String +): CardPaymentInfo { + return requestCardPayment( + amount = amount, + description = description, + timeout = timeoutInSeconds.seconds, + cardHolderOffer = cardHolderOffer, + cardParams = cardParams + ) +} diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/PhoenixExposure.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/PhoenixExposure.kt index 4b223da58..4aa65369d 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/PhoenixExposure.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/utils/PhoenixExposure.kt @@ -1,8 +1,6 @@ package fr.acinq.phoenix.utils import fr.acinq.lightning.MilliSatoshi -import fr.acinq.lightning.utils.UUID -import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.data.LocalChannelInfo import fr.acinq.phoenix.data.availableForReceive import fr.acinq.phoenix.data.canRequestLiquidity