diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index cd62e7453..e48a256fe 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 02EDCC7635BCD84768554D9C /* StorageModelListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A762BDA473947F1A7EF199 /* StorageModelListViews.swift */; }; 02EED2FC7EAC55B96ECEAAD6 /* DWLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 59C18FCF827D815007BAEF29 /* DWLogger.m */; }; 0308E777B29CC6DE1DB30FDD /* PlatformSyncStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A58BF094C42E410DD1A71B6 /* PlatformSyncStatusScreen.swift */; }; + 0438F3D117619B14594F95E6 /* DashConnectUriTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7CAFFF191922CBF6075212 /* DashConnectUriTests.swift */; }; 06132521E7B4D21B4A1597E7 /* PlatformSendExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DCDC3A2B92601BB1E61FD9 /* PlatformSendExecutor.swift */; }; 06E16A070A3C48883BBDA137 /* BIP70InteractiveCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36BCF99A4D30077C9C9B0EB /* BIP70InteractiveCoordinator.swift */; }; 07B6BD15F525AAF000B53162 /* AddContactScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3498B2E247B2CADBF02DC0B1 /* AddContactScreen.swift */; }; @@ -101,7 +102,7 @@ 16AA1B384A9758C1DA97676A /* SwiftDashSDKHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E181EB71C8803101EA789AA4 /* SwiftDashSDKHost.swift */; }; 17ED79A8B56522CD03A8CD5E /* ShieldedTransferCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D2C8F8102C5C603DB63569F /* ShieldedTransferCoordinator.swift */; }; 197F19679BBAABA2E99503B8 /* DWAvatarUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A461F75E1E170FFE3294337 /* DWAvatarUploadClient.swift */; }; - 19BF3F10240A082811AAC1EB /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + 19BF3F10240A082811AAC1EB /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 19CF9D60B276558AA48380C2 /* SwiftDashSDKSPVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F37C177039C76661FA2DB2 /* SwiftDashSDKSPVCoordinator.swift */; }; 1A9AA76AC3F7489DCE8850ED /* WalletAccountsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F334C7FE582BF48AB47BDAA2 /* WalletAccountsViewModel.swift */; }; 1AF81D8C73A81CB927C3AE0E /* libPods-DashWalletScreenshotsUITests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B56B94951BA51EAC9E0A6E1 /* libPods-DashWalletScreenshotsUITests.a */; }; @@ -432,7 +433,6 @@ 478C98372943A60000FAA0F0 /* PaymentMethodCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478C98362943A60000FAA0F0 /* PaymentMethodCell.swift */; }; 478C983C2945801D00FAA0F0 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478C983B2945801D00FAA0F0 /* Constants.swift */; }; 47976E4A2987772700612988 /* AmountObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47976E492987772700612988 /* AmountObjectTests.swift */; }; - 47976E4B2987781A00612988 /* DWAmountInputValidator.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A2120E92214566A009906DC /* DWAmountInputValidator.m */; }; 479DBDDD2995168C00F30AF1 /* Transactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 479DBDDC2995168C00F30AF1 /* Transactions.swift */; }; 479E7924287C00EC00D0F7D7 /* DatabaseConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 479E7923287C00EC00D0F7D7 /* DatabaseConnection.swift */; }; 47A184E5299CC19A0017E32C /* rates.json in Resources */ = {isa = PBXBuildFile; fileRef = 47A184E4299CC19A0017E32C /* rates.json */; }; @@ -723,16 +723,16 @@ 754119E01CDA93FF0042DC51 /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BAE12BE61B2DEE7F00895CC5 /* NotificationCenter.framework */; }; 754495DD2AE91B6300492817 /* GroupedRequestCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754495DC2AE91B6300492817 /* GroupedRequestCell.swift */; }; 754495DF2AE91D3500492817 /* UsernameRequestCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754495DE2AE91D3500492817 /* UsernameRequestCell.swift */; }; - 754565C82DAA52A000DA4E8E /* CTXSpendAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C42DAA52A000DA4E8E /* CTXSpendAPI.swift */; }; - 754565CA2DAA52A000DA4E8E /* CTXSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C72DAA52A000DA4E8E /* CTXSpendRepository.swift */; }; - 754565CB2DAA52A000DA4E8E /* CTXSpendEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C52DAA52A000DA4E8E /* CTXSpendEndpoint.swift */; }; - 754565CC2DAA52A000DA4E8E /* CTXSpendAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C42DAA52A000DA4E8E /* CTXSpendAPI.swift */; }; - 754565CE2DAA52A000DA4E8E /* CTXSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C72DAA52A000DA4E8E /* CTXSpendRepository.swift */; }; - 754565CF2DAA52A000DA4E8E /* CTXSpendEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C52DAA52A000DA4E8E /* CTXSpendEndpoint.swift */; }; + 754565C82DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C42DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift */; }; + 754565CA2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C72DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift */; }; + 754565CB2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C52DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift */; }; + 754565CC2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C42DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift */; }; + 754565CE2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C72DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift */; }; + 754565CF2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C52DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift */; }; 754565D12DABA5F300DA4E8E /* MerchantTypesDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565D02DABA5EB00DA4E8E /* MerchantTypesDialog.swift */; }; 754565D22DABA5F300DA4E8E /* MerchantTypesDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565D02DABA5EB00DA4E8E /* MerchantTypesDialog.swift */; }; - 7545ED532DA91A2F0075F45C /* CTXConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED522DA91A2F0075F45C /* CTXConstants.swift */; }; - 7545ED542DA91A2F0075F45C /* CTXConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED522DA91A2F0075F45C /* CTXConstants.swift */; }; + 7545ED532DA91A2F0075F45C /* DashSpend/CTXConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED522DA91A2F0075F45C /* DashSpend/CTXConstants.swift */; }; + 7545ED542DA91A2F0075F45C /* DashSpend/CTXConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED522DA91A2F0075F45C /* DashSpend/CTXConstants.swift */; }; 7545ED572DA91AF10075F45C /* DashSpendUserAuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED562DA91AEA0075F45C /* DashSpendUserAuthViewModel.swift */; }; 7545ED582DA91AF10075F45C /* DashSpendUserAuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED562DA91AEA0075F45C /* DashSpendUserAuthViewModel.swift */; }; 7545ED5D2DA91F590075F45C /* DashSpendTermsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED5C2DA91F590075F45C /* DashSpendTermsScreen.swift */; }; @@ -823,8 +823,8 @@ 759C8FA02B593589004B1305 /* CrowdNodeAPYView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759C8F9E2B593589004B1305 /* CrowdNodeAPYView.swift */; }; 75A0A3F32CA7DBCF003ED48B /* TimeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A0A3F22CA7DBCF003ED48B /* TimeUtils.swift */; }; 75A0A3F42CA7DBCF003ED48B /* TimeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A0A3F22CA7DBCF003ED48B /* TimeUtils.swift */; }; - 75A2F3032DE48C860046BE17 /* CTXSpendTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A2F3022DE48C860046BE17 /* CTXSpendTokenService.swift */; }; - 75A2F3042DE48C860046BE17 /* CTXSpendTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A2F3022DE48C860046BE17 /* CTXSpendTokenService.swift */; }; + 75A2F3032DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A2F3022DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift */; }; + 75A2F3042DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A2F3022DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift */; }; 75A8C1652AE5726B0042256E /* UsernameRequestsDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1632AE5725C0042256E /* UsernameRequestsDAO.swift */; }; 75A8C1672AE5734A0042256E /* UsernameRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1662AE5734A0042256E /* UsernameRequest.swift */; }; 75A8C1692AE6A1AC0042256E /* VotingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1682AE6A1AC0042256E /* VotingViewModel.swift */; }; @@ -940,6 +940,7 @@ 8A7607606A594C6E99A2FAC7 /* DerivationPathKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC1F4DB87C442BEABDF0DB2 /* DerivationPathKeysView.swift */; }; 8AF94168DE3494EAF0811D77 /* PaymentURIBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E3F6CC617DA824F467AADC /* PaymentURIBuilder.swift */; }; 8B8898971DC24152E171DABC /* PaymentsLandingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */; }; + 8D66A1B0E97F5B50411765B4 /* Secp256k1Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C172F43568AE0A5AFDB5349 /* Secp256k1Tests.swift */; }; 8DD6758D2AC6F5BB23D5504D /* WalletSendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4FDA5A52AB80D7F349A7AF /* WalletSendService.swift */; }; 8DF3D24EE3B247B79AE9CAB5 /* BuySellPortalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */; }; 8E3AF21ED19AEC0FD89A63FC /* IdentitiesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528594007157A56ABDEAD552 /* IdentitiesScreen.swift */; }; @@ -993,8 +994,8 @@ AA0000012DUMMYID001234567 /* AddProviderToGiftCardsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000032DUMMYID001234567 /* AddProviderToGiftCardsTable.swift */; }; AA00000228D9A3DB00223B77 /* BuySellPortalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000028D9A3DB00223B77 /* BuySellPortalView.swift */; }; AA0000022DUMMYID001234567 /* AddProviderToGiftCardsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000032DUMMYID001234567 /* AddProviderToGiftCardsTable.swift */; }; - AA0000042DUMMYID001234567 /* GiftCardProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000062DUMMYID001234567 /* GiftCardProvider.swift */; }; - AA0000052DUMMYID001234567 /* GiftCardProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000062DUMMYID001234567 /* GiftCardProvider.swift */; }; + AA0000042DUMMYID001234567 /* DashSpend/GiftCardProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000062DUMMYID001234567 /* DashSpend/GiftCardProvider.swift */; }; + AA0000052DUMMYID001234567 /* DashSpend/GiftCardProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000062DUMMYID001234567 /* DashSpend/GiftCardProvider.swift */; }; AA0001022AA9F58E00A1B201 /* MayaPortalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001012AA9F58E00A1B201 /* MayaPortalView.swift */; }; AA0001032AA9F58E00A1B201 /* MayaPortalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001012AA9F58E00A1B201 /* MayaPortalView.swift */; }; AA0001052AA9F58E00A1B202 /* MayaPortalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001042AA9F58E00A1B202 /* MayaPortalViewController.swift */; }; @@ -1081,12 +1082,17 @@ AA0031022FD0000000000012 /* IconName+DashIconSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0031012FD0000000000011 /* IconName+DashIconSource.swift */; }; AA0031032FD0000000000013 /* IconName+DashIconSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0031012FD0000000000011 /* IconName+DashIconSource.swift */; }; AA00F1012FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00F1002FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift */; }; + AA03A0012FF3000000000001 /* KeyExchangeCryptoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03A0032FF3000000000003 /* KeyExchangeCryptoTests.swift */; }; + AA03A0022FF3000000000002 /* LoginKeyDerivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03A0042FF3000000000004 /* LoginKeyDerivationTests.swift */; }; + AA04A0012FF4000000000001 /* DashConnectDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04A0032FF4000000000003 /* DashConnectDataSourceTests.swift */; }; + AA05A0012FF5000000000001 /* DashConnectStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05A0032FF5000000000003 /* DashConnectStoreTests.swift */; }; + AA06A0012FF6000000000001 /* PlatformDashConnectDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA06A0032FF6000000000003 /* PlatformDashConnectDataSourceTests.swift */; }; AA22BB33CC44DD55EE66FF78 /* BuyReceiveFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA22BB33CC44DD55EE66FF77 /* BuyReceiveFlowUITests.swift */; }; AA29B7041CF55787428E1A04 /* libPods-WatchApp Extension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B9BF7550EF8D4BA009CB1EB5 /* libPods-WatchApp Extension.a */; }; ABA7230759067F4D4EB39AE9 /* SwapTransactionStatusHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B5469F5588395FA0668C4B /* SwapTransactionStatusHostingController.swift */; }; AC938F974CADCEA4AC61B922 /* StorageExplorerUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F8E2ECDE79849D01F613ADC /* StorageExplorerUnavailableView.swift */; }; ACAB47BF67F9E00D253166D4 /* DWRegistrationPhaseAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3839568283FDC3A6933824 /* DWRegistrationPhaseAdapter.swift */; }; - AEAE60406265A73AFD573FD1 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + AEAE60406265A73AFD573FD1 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; B059B6E200A57A699E6EED84 /* libPods-DashWalletTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1940CA64E8FD6DDD0CFC14A2 /* libPods-DashWalletTests.a */; }; B1100000B1100000B1100010 /* BuyEnterAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1100000B1100000B1100002 /* BuyEnterAmountView.swift */; }; B1100000B1100000B1100011 /* BuyEnterAmountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1100000B1100000B1100003 /* BuyEnterAmountViewModel.swift */; }; @@ -1146,12 +1152,12 @@ BAA6E3EF1BD5CA0E00773205 /* BRAWBalanceInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA6E3EE1BD5CA0E00773205 /* BRAWBalanceInterfaceController.swift */; }; BAA6E3F11BD5CA5900773205 /* BRAWReceiveMoneyInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA6E3F01BD5CA5900773205 /* BRAWReceiveMoneyInterfaceController.swift */; }; BAE12C061B2DEEF700895CC5 /* DWTodayViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = BAE12C051B2DEEF700895CC5 /* DWTodayViewController.m */; }; - BB0000012DUMMYID001234567 /* DashSpendError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000072DUMMYID001234567 /* DashSpendError.swift */; }; - BB0000022DUMMYID001234567 /* DashSpendError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000072DUMMYID001234567 /* DashSpendError.swift */; }; - BB0000032DUMMYID001234567 /* DashSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000082DUMMYID001234567 /* DashSpendRepository.swift */; }; - BB0000042DUMMYID001234567 /* DashSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000082DUMMYID001234567 /* DashSpendRepository.swift */; }; - BB0000052DUMMYID001234567 /* DashSpendRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000092DUMMYID001234567 /* DashSpendRepositoryFactory.swift */; }; - BB0000062DUMMYID001234567 /* DashSpendRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000092DUMMYID001234567 /* DashSpendRepositoryFactory.swift */; }; + BB0000012DUMMYID001234567 /* DashSpend/DashSpendError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000072DUMMYID001234567 /* DashSpend/DashSpendError.swift */; }; + BB0000022DUMMYID001234567 /* DashSpend/DashSpendError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000072DUMMYID001234567 /* DashSpend/DashSpendError.swift */; }; + BB0000032DUMMYID001234567 /* DashSpend/DashSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000082DUMMYID001234567 /* DashSpend/DashSpendRepository.swift */; }; + BB0000042DUMMYID001234567 /* DashSpend/DashSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000082DUMMYID001234567 /* DashSpend/DashSpendRepository.swift */; }; + BB0000052DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000092DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift */; }; + BB0000062DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000092DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift */; }; BB779AE4D9B43803EAF0CB6F /* WalletEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0B188220763649450C9423 /* WalletEnvironment.swift */; }; BC2677B2880883067AE18DC5 /* libPods-dashwallet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 797D9070BF54A3533190584E /* libPods-dashwallet.a */; }; BCBED608CAAABA10839A0ADE /* SwiftDashSDKWalletState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C22064915D913252F56184 /* SwiftDashSDKWalletState.swift */; }; @@ -1387,7 +1393,7 @@ C9D2C70C2A320AA000D15901 /* DWUpholdLogoutTutorialViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A9FFE652230FF4600956D5F /* DWUpholdLogoutTutorialViewController.m */; }; C9D2C70D2A320AA000D15901 /* CBAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CF46A429654E190067B6EE /* CBAccount.swift */; }; C9D2C70E2A320AA000D15901 /* Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF180429070B720025803E /* Types.swift */; }; - C9D2C70F2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C70F2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7112A320AA000D15901 /* Coinbase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4774DCDA28F3FA9C008CF87D /* Coinbase.swift */; }; C9D2C7122A320AA000D15901 /* SyncModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F452072A11F28600825057 /* SyncModel.swift */; }; C9D2C7132A320AA000D15901 /* ReceiveContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F42FAA29DC1098001BC549 /* ReceiveContentView.swift */; }; @@ -1407,7 +1413,7 @@ C9D2C7242A320AA000D15901 /* WKWebView+CrowdNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110D1780298BA9AF005BEB30 /* WKWebView+CrowdNode.swift */; }; C9D2C7252A320AA000D15901 /* SyncingActivityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FA3AFE29350929008D58DC /* SyncingActivityMonitor.swift */; }; C9D2C7262A320AA000D15901 /* EmptyUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F42FAF29DC27F4001BC549 /* EmptyUIView.swift */; }; - C9D2C7272A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7272A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7282A320AA000D15901 /* DWQRScanModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AC92C891FEB0B8B008CAEE0 /* DWQRScanModel.m */; }; C9D2C72A2A320AA000D15901 /* SyncView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F451F62A0CAE1300825057 /* SyncView.swift */; }; C9D2C72B2A320AA000D15901 /* SuccessfulOperationStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47522F4F2927CB9000EE143E /* SuccessfulOperationStatusViewController.swift */; }; @@ -1438,7 +1444,7 @@ C9D2C7492A320AA000D15901 /* DWCurrencyObject.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AA87CF926E5681100F0CEA6 /* DWCurrencyObject.m */; }; C9D2C74A2A320AA000D15901 /* ShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94F5E8F29D4060A0034FD57 /* ShortcutsView.swift */; }; C9D2C74B2A320AA000D15901 /* DWFormSectionModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A9FFE3E2230FF4600956D5F /* DWFormSectionModel.m */; }; - C9D2C74C2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C74C2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C74D2A320AA000D15901 /* CoinbasePlaceBuyOrderResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFA628C896BC000427E7 /* CoinbasePlaceBuyOrderResponse.swift */; }; C9D2C74E2A320AA000D15901 /* ExploreMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AE8BDC28C1305E00490F5E /* ExploreMapView.swift */; }; C9D2C74F2A320AA000D15901 /* DWSeedPhraseRow.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AD1CE9322DD078600C99324 /* DWSeedPhraseRow.m */; }; @@ -1460,7 +1466,7 @@ C9D2C7642A320AA000D15901 /* BasePageSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F2C67C28602D4F00C2B774 /* BasePageSheetViewController.swift */; }; C9D2C7662A320AA000D15901 /* CrowdNodeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 119E8D0329051F9900D406C1 /* CrowdNodeRequest.swift */; }; C9D2C7672A320AA000D15901 /* CoinbaseExchangeRateResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFAF28C896BC000427E7 /* CoinbaseExchangeRateResponse.swift */; }; - C9D2C7692A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7692A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C76A2A320AA000D15901 /* CrowdNodePortalItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 117728A2297A7D24006F1553 /* CrowdNodePortalItem.swift */; }; C9D2C76B2A320AA000D15901 /* BaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C661B528FE75A700028A8D /* BaseViewController.swift */; }; C9D2C76C2A320AA000D15901 /* AccountListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4751CABF296EFD2900F63AC4 /* AccountListController.swift */; }; @@ -1509,11 +1515,11 @@ C9D2C7A52A320AA000D15901 /* DWModalPresentationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A0C69C523142AA4001B8C90 /* DWModalPresentationController.m */; }; C9D2C7A72A320AA000D15901 /* ActionButtonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C661B328FDCF7800028A8D /* ActionButtonViewController.swift */; }; C9D2C7A82A320AA000D15901 /* BuySellServiceItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4774DCE428F4668B008CF87D /* BuySellServiceItemCell.swift */; }; - C9D2C7AA2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7AA2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7AB2A320AA000D15901 /* DWBaseFormTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7BC82347E0D700451078 /* DWBaseFormTableViewCell.m */; }; C9D2C7AC2A320AA000D15901 /* AccountCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4751CAC6296FAEBB00F63AC4 /* AccountCell.swift */; }; C9D2C7B12A320AA000D15901 /* CoinbaseBaseIDForCurrencyResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFA028C896BC000427E7 /* CoinbaseBaseIDForCurrencyResponse.swift */; }; - C9D2C7B22A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7B22A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7B32A320AA000D15901 /* SpecifyAmountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AC8412297822E000BD1B49 /* SpecifyAmountViewController.swift */; }; C9D2C7B42A320AA000D15901 /* DWUpholdTransactionObject+DWView.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AFCB9C323BE76EC00FF59A6 /* DWUpholdTransactionObject+DWView.m */; }; C9D2C7B62A320AA000D15901 /* CrowdNodeDepositTx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114CFECF296469D9005F421B /* CrowdNodeDepositTx.swift */; }; @@ -1524,14 +1530,14 @@ C9D2C7BD2A320AA000D15901 /* DWSharedUIConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B9E2822FB1C5D00FF8653 /* DWSharedUIConstants.m */; }; C9D2C7BE2A320AA000D15901 /* BRAppleWatchTransactionData.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A858A0E237EE89B0097A7B5 /* BRAppleWatchTransactionData.m */; }; C9D2C7C02A320AA000D15901 /* HomeHeaderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F4520A2A1209D100825057 /* HomeHeaderModel.swift */; }; - C9D2C7C22A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7C22A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7C32A320AA000D15901 /* DWDPRegistrationStatus.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF01DA243F4559003718DC /* DWDPRegistrationStatus.m */; }; C9D2C7C42A320AA000D15901 /* CurrencyExchanger_Objc.m in Sources */ = {isa = PBXBuildFile; fileRef = 472D13EC299E6579006903F1 /* CurrencyExchanger_Objc.m */; }; C9D2C7C62A320AA000D15901 /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471DD1B7290A92CD00E030C8 /* Tools.swift */; }; C9D2C7C72A320AA000D15901 /* BuySellPortalModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478A2C7028DC554200AD1420 /* BuySellPortalModel.swift */; }; C9D2C7C92A320AA000D15901 /* BuyDashViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478C98252942DC2700FAA0F0 /* BuyDashViewController.swift */; }; C9D2C7CA2A320AA000D15901 /* DWModalDismissalAnimation.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A0C69CF23143435001B8C90 /* DWModalDismissalAnimation.m */; }; - C9D2C7CB2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7CB2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7CD2A320AA000D15901 /* UIViewController+DashWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CDEECD294A3CF2008AE06D /* UIViewController+DashWallet.swift */; }; C9D2C7CE2A320AA000D15901 /* CrowdNodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470617D5299A671900DCC667 /* CrowdNodeCell.swift */; }; C9D2C7CF2A320AA000D15901 /* MerchantListLocationOffCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AE8BD128C1305E00490F5E /* MerchantListLocationOffCell.swift */; }; @@ -1621,7 +1627,7 @@ C9D2C8452A320AA000D15901 /* DWRecoverViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A74EFEC2305318000C475EB /* DWRecoverViewController.m */; }; C9D2C8482A320AA000D15901 /* CoinbaseTransactionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFA828C896BC000427E7 /* CoinbaseTransactionsRequest.swift */; }; C9D2C8492A320AA000D15901 /* DWAppRootViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A9CEBAC22E1DA4000A50237 /* DWAppRootViewController.m */; }; - C9D2C84A2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C84A2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C84B2A320AA000D15901 /* UITableView+DashWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F005FE297164600029EB10 /* UITableView+DashWallet.swift */; }; C9D2C84D2A320AA000D15901 /* ShortcutCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94F5E8D29D404850034FD57 /* ShortcutCell.swift */; }; C9D2C84F2A320AA000D15901 /* UIColor+DWStyle.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A44312022CCA2A0009BAF7F /* UIColor+DWStyle.m */; }; @@ -1641,7 +1647,7 @@ C9D2C8612A320AA000D15901 /* CoinbaseTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFA328C896BC000427E7 /* CoinbaseTokenResponse.swift */; }; C9D2C8622A320AA000D15901 /* BackupInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F42FB129DD5141001BC549 /* BackupInfoViewController.swift */; }; C9D2C8652A320AA000D15901 /* DWCaptureSessionManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7BAD234770C900451078 /* DWCaptureSessionManager.m */; }; - C9D2C86A2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C86A2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C86B2A320AA000D15901 /* AmountPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472D13E5299E3C81006903F1 /* AmountPreviewView.swift */; }; C9D2C86C2A320AA000D15901 /* ModalNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4759D513292FEFFB002F20DC /* ModalNavigationController.swift */; }; C9D2C86E2A320AA000D15901 /* TransferAmountModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A50F3A2913BC0900C70123 /* TransferAmountModel.swift */; }; @@ -1673,7 +1679,7 @@ C9D2C8952A320AA000D15901 /* ConvertCryptoOrderPreviewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4751CACF2970224D00F63AC4 /* ConvertCryptoOrderPreviewModel.swift */; }; C9D2C8972A320AA000D15901 /* RatesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A2E3A82972B15F0032A63B /* RatesProvider.swift */; }; C9D2C8982A320AA000D15901 /* CBAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A2A2E8293E612900938DB7 /* CBAuth.swift */; }; - C9D2C8992A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C8992A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C89A2A320AA000D15901 /* CoinbaseCreateAddressesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFAE28C896BC000427E7 /* CoinbaseCreateAddressesRequest.swift */; }; C9D2C89E2A320AA000D15901 /* AddressUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471A2604289ACD5C0056B7B2 /* AddressUserInfo.swift */; }; C9D2C8A02A320AA000D15901 /* SendCoinsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E47BAA28EB38510097CFA0 /* SendCoinsService.swift */; }; @@ -1719,7 +1725,7 @@ C9D2C8DD2A320AA000D15901 /* AtmDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AE8BD528C1305E00490F5E /* AtmDataProvider.swift */; }; C9D2C8DE2A320AA000D15901 /* GettingStartedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110C67942921147F006B580C /* GettingStartedViewController.swift */; }; C9D2C8DF2A320AA000D15901 /* NetworkUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477A963D292CD27D0013605B /* NetworkUnavailableView.swift */; }; - C9D2C8E02A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C8E02A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C8E12A320AA000D15901 /* DWLockScreenViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A63004D2328F37C00827825 /* DWLockScreenViewController.m */; }; C9D2C8E22A320AA000D15901 /* UpholdTransferViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4789D2302981069700BAFEFA /* UpholdTransferViewController.swift */; }; C9D2C8E32A320AA000D15901 /* DWVerifySeedPhraseViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AD1CE8322DC9B5D00C99324 /* DWVerifySeedPhraseViewController.m */; }; @@ -1757,7 +1763,7 @@ C9D2C9272A320AA000D15901 /* SyncView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2A4E531922EA382B00E5168A /* SyncView.xib */; }; C9D2C9292A320AA000D15901 /* BackupInfoItemView.xib in Resources */ = {isa = PBXBuildFile; fileRef = C9F42FB529DD8702001BC549 /* BackupInfoItemView.xib */; }; C9D2C92A2A320AA000D15901 /* explore.db in Resources */ = {isa = PBXBuildFile; fileRef = 47AE8BAF28BFF28400490F5E /* explore.db */; }; - C9D2C92B2A320AA000D15901 /* BuildFile in Resources */ = {isa = PBXBuildFile; }; + C9D2C92B2A320AA000D15901 /* (null) in Resources */ = {isa = PBXBuildFile; }; C9D2C92C2A320AA000D15901 /* Coinbase.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0F3693812919A70B007F4E91 /* Coinbase.storyboard */; }; C9D2C92F2A320AA000D15901 /* ReceiveContentView.xib in Resources */ = {isa = PBXBuildFile; fileRef = C9F42FAC29DC115A001BC549 /* ReceiveContentView.xib */; }; C9D2C9302A320AA000D15901 /* VerifySeedPhrase.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2AD1CE8522DC9B7300C99324 /* VerifySeedPhrase.storyboard */; }; @@ -1858,7 +1864,7 @@ DC485FD6F26BDE9D539EFA04 /* SwiftDashSDKWalletWiper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15DF22D94904D5A8CD596ED8 /* SwiftDashSDKWalletWiper.swift */; }; DD985ADD7FFF3B9E3C344DED /* SwiftDashSDKSPVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F37C177039C76661FA2DB2 /* SwiftDashSDKSPVCoordinator.swift */; }; DE146DFF383C96BD9DDD692A /* WalletAccountDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD0199D74601A6150ABC8D1 /* WalletAccountDetailViewModel.swift */; }; - DE3A167A235B79D705C0A962 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + DE3A167A235B79D705C0A962 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; E026CE440F583B5B64395214 /* BIP70SendAuthorizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379F0351A49BFB8CCA87926D /* BIP70SendAuthorizer.swift */; }; E039AE9F21B1A3BF80B16038 /* PinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FE91D9C90E736DB1634A5 /* PinStore.swift */; }; E16E2BA180551E3B6661B9F4 /* PlatformCreditsFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C830DD6D63115525925F75 /* PlatformCreditsFormatter.swift */; }; @@ -1901,8 +1907,8 @@ FB8ACEB622E0502200EE5035 /* DWUpholdMainnetConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = FB8ACEB522E0502100EE5035 /* DWUpholdMainnetConstants.m */; }; FBE008F4BBBA9CC7BF299B93 /* PaymentProtocolTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3B307E94B354351D4E3D3B /* PaymentProtocolTransport.swift */; }; FCDF0FF6DC442DBE3C9713FD /* DiagnosticLogExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0448A6769BCF20987BE9960C /* DiagnosticLogExporter.swift */; }; - FF0000012DUMMYID001234567 /* PiggyCardsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000042DUMMYID001234567 /* PiggyCardsConstants.swift */; }; - FF0000022DUMMYID001234567 /* PiggyCardsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000042DUMMYID001234567 /* PiggyCardsConstants.swift */; }; + FF0000012DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000042DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift */; }; + FF0000022DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000042DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift */; }; FF0000052DUMMYID001234567 /* PiggyCardsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000062DUMMYID001234567 /* PiggyCardsModels.swift */; }; FF0000072DUMMYID001234567 /* PiggyCardsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000062DUMMYID001234567 /* PiggyCardsModels.swift */; }; FF0000082DUMMYID001234567 /* BarcodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000092DUMMYID001234567 /* BarcodeScanner.swift */; }; @@ -1930,8 +1936,8 @@ isa = PBXContainerItemProxy; containerPortal = 75D5F3B6191EC270004AB296 /* Project object */; proxyType = 1; - remoteGlobalIDString = 75D5F3BD191EC270004AB296; - remoteInfo = DashWallet; + remoteGlobalIDString = C9D2C68A2A320AA000D15901; + remoteInfo = dashpay; }; BA913BEC1BD57E4D005A7C0E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -2282,6 +2288,7 @@ 2A7A7C1C234B771400451078 /* DWLocalCurrencyModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWLocalCurrencyModel.m; sourceTree = ""; }; 2A7A7C1E234B79B700451078 /* DWLocalCurrencyTableViewCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWLocalCurrencyTableViewCell.h; sourceTree = ""; }; 2A7A7C1F234B79B700451078 /* DWLocalCurrencyTableViewCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWLocalCurrencyTableViewCell.m; sourceTree = ""; }; + 2A7CAFFF191922CBF6075212 /* DashConnectUriTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DashConnectUriTests.swift; path = DashConnect/DashConnectUriTests.swift; sourceTree = ""; }; 2A7F3B19238C646600DEA3EF /* DWAdvancedSecurityViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWAdvancedSecurityViewController.h; sourceTree = ""; }; 2A7F3B1A238C646600DEA3EF /* DWAdvancedSecurityViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWAdvancedSecurityViewController.m; sourceTree = ""; }; 2A7F3B1D238C651200DEA3EF /* DWSecurityStatusView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWSecurityStatusView.h; sourceTree = ""; }; @@ -2863,11 +2870,11 @@ 753FDBED2AECF52B0005EEC3 /* UsernameVoting.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = UsernameVoting.storyboard; sourceTree = ""; }; 754495DC2AE91B6300492817 /* GroupedRequestCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedRequestCell.swift; sourceTree = ""; }; 754495DE2AE91D3500492817 /* UsernameRequestCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameRequestCell.swift; sourceTree = ""; }; - 754565C42DAA52A000DA4E8E /* CTXSpendAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendAPI.swift; sourceTree = ""; }; - 754565C52DAA52A000DA4E8E /* CTXSpendEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendEndpoint.swift; sourceTree = ""; }; - 754565C72DAA52A000DA4E8E /* CTXSpendRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendRepository.swift; sourceTree = ""; }; + 754565C42DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendAPI.swift; sourceTree = ""; }; + 754565C52DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendEndpoint.swift; sourceTree = ""; }; + 754565C72DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendRepository.swift; sourceTree = ""; }; 754565D02DABA5EB00DA4E8E /* MerchantTypesDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MerchantTypesDialog.swift; sourceTree = ""; }; - 7545ED522DA91A2F0075F45C /* CTXConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTXConstants.swift; sourceTree = ""; }; + 7545ED522DA91A2F0075F45C /* DashSpend/CTXConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTXConstants.swift; sourceTree = ""; }; 7545ED562DA91AEA0075F45C /* DashSpendUserAuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpendUserAuthViewModel.swift; sourceTree = ""; }; 7545ED5C2DA91F590075F45C /* DashSpendTermsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpendTermsScreen.swift; sourceTree = ""; }; 7549BE522DEAEA14004F0BAF /* MetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataProvider.swift; sourceTree = ""; }; @@ -2930,7 +2937,7 @@ 759AFDE22CC67E89007072D2 /* VotingInfoScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotingInfoScreen.swift; sourceTree = ""; }; 759C8F9E2B593589004B1305 /* CrowdNodeAPYView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrowdNodeAPYView.swift; sourceTree = ""; }; 75A0A3F22CA7DBCF003ED48B /* TimeUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeUtils.swift; sourceTree = ""; }; - 75A2F3022DE48C860046BE17 /* CTXSpendTokenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendTokenService.swift; sourceTree = ""; }; + 75A2F3022DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendTokenService.swift; sourceTree = ""; }; 75A8C1632AE5725C0042256E /* UsernameRequestsDAO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameRequestsDAO.swift; sourceTree = ""; }; 75A8C1662AE5734A0042256E /* UsernameRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameRequest.swift; sourceTree = ""; }; 75A8C1682AE6A1AC0042256E /* VotingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotingViewModel.swift; sourceTree = ""; }; @@ -3012,6 +3019,7 @@ 7A30000130A1000000000001 /* TransactionDirectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDirectionTests.swift; sourceTree = ""; }; 7B56B94951BA51EAC9E0A6E1 /* libPods-DashWalletScreenshotsUITests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-DashWalletScreenshotsUITests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuySellPortalScreen.swift; sourceTree = ""; }; + 7C172F43568AE0A5AFDB5349 /* Secp256k1Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Secp256k1Tests.swift; path = DashConnect/Secp256k1Tests.swift; sourceTree = ""; }; 7E62262597A8B22870184A36 /* SDKIdentityProfileSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKIdentityProfileSheet.swift; sourceTree = ""; }; 7FA744C4A6023E26A3B5B860 /* DWLogger.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = DWLogger.h; sourceTree = ""; }; 82926C69F3BB675BEF1A1FFD /* DWProfileUpdateCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWProfileUpdateCoordinator.swift; sourceTree = ""; }; @@ -3040,7 +3048,7 @@ A876A874736D422F781F797F /* SwiftDashSDKReceiveAddressReader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKReceiveAddressReader.swift; sourceTree = ""; }; AA00000028D9A3DB00223B77 /* BuySellPortalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuySellPortalView.swift; sourceTree = ""; }; AA0000032DUMMYID001234567 /* AddProviderToGiftCardsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProviderToGiftCardsTable.swift; sourceTree = ""; }; - AA0000062DUMMYID001234567 /* GiftCardProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/GiftCardProvider.swift; sourceTree = ""; }; + AA0000062DUMMYID001234567 /* DashSpend/GiftCardProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/GiftCardProvider.swift; sourceTree = ""; }; AA0001012AA9F58E00A1B201 /* MayaPortalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaPortalView.swift; sourceTree = ""; }; AA0001042AA9F58E00A1B202 /* MayaPortalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaPortalViewController.swift; sourceTree = ""; }; AA0002012BA0F58E00A1B301 /* SwapCryptoCurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapCryptoCurrency.swift; sourceTree = ""; }; @@ -3086,6 +3094,11 @@ AA0030202FD0000000000020 /* CoinbaseMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinbaseMetadataProvider.swift; sourceTree = ""; }; AA0031012FD0000000000011 /* IconName+DashIconSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IconName+DashIconSource.swift"; sourceTree = ""; }; AA00F1002FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangeAddressLookupContextTests.swift; sourceTree = ""; }; + AA03A0032FF3000000000003 /* KeyExchangeCryptoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeyExchangeCryptoTests.swift; path = DashConnect/KeyExchangeCryptoTests.swift; sourceTree = ""; }; + AA03A0042FF3000000000004 /* LoginKeyDerivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginKeyDerivationTests.swift; path = DashConnect/LoginKeyDerivationTests.swift; sourceTree = ""; }; + AA04A0032FF4000000000003 /* DashConnectDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DashConnectDataSourceTests.swift; path = DashConnect/DashConnectDataSourceTests.swift; sourceTree = ""; }; + AA05A0032FF5000000000003 /* DashConnectStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DashConnectStoreTests.swift; path = DashConnect/DashConnectStoreTests.swift; sourceTree = ""; }; + AA06A0032FF6000000000003 /* PlatformDashConnectDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlatformDashConnectDataSourceTests.swift; path = DashConnect/PlatformDashConnectDataSourceTests.swift; sourceTree = ""; }; AA22BB33CC44DD55EE66FF77 /* BuyReceiveFlowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuyReceiveFlowUITests.swift; sourceTree = ""; }; AA29A8515217AC4232F794C2 /* Pods-TodayExtension.testnet.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TodayExtension.testnet.xcconfig"; path = "Target Support Files/Pods-TodayExtension/Pods-TodayExtension.testnet.xcconfig"; sourceTree = ""; }; AD6615F1246B5935365B05BD /* NavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = ""; }; @@ -3140,9 +3153,9 @@ BAE12BE61B2DEE7F00895CC5 /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; BAE12BEA1B2DEE7F00895CC5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BAE12C051B2DEEF700895CC5 /* DWTodayViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DWTodayViewController.m; sourceTree = ""; }; - BB0000072DUMMYID001234567 /* DashSpendError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendError.swift; sourceTree = ""; }; - BB0000082DUMMYID001234567 /* DashSpendRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendRepository.swift; sourceTree = ""; }; - BB0000092DUMMYID001234567 /* DashSpendRepositoryFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendRepositoryFactory.swift; sourceTree = ""; }; + BB0000072DUMMYID001234567 /* DashSpend/DashSpendError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendError.swift; sourceTree = ""; }; + BB0000082DUMMYID001234567 /* DashSpend/DashSpendRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendRepository.swift; sourceTree = ""; }; + BB0000092DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendRepositoryFactory.swift; sourceTree = ""; }; BCD0199D74601A6150ABC8D1 /* WalletAccountDetailViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletAccountDetailViewModel.swift; sourceTree = ""; }; C0D1A08496F925D17031493E /* OrderPreviewFeeRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OrderPreviewFeeRow.swift; sourceTree = ""; }; C0DE5A0126C7000000000002 /* SwiftDashSDKCoreLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKCoreLifecycleTests.swift; sourceTree = ""; }; @@ -3642,7 +3655,7 @@ FBF3F4301E42B02800C7248E /* ImageIO.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImageIO.framework; path = System/Library/Frameworks/ImageIO.framework; sourceTree = SDKROOT; }; FE0000000000000000000061 /* ExtendedPublicKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExtendedPublicKeysView.swift; path = DashWallet/Sources/UI/Menu/Tools/ExtendedKeys/ExtendedPublicKeysView.swift; sourceTree = SOURCE_ROOT; }; FE64C10DA9C338D408982EBE /* PaymentsLandingViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentsLandingViewModel.swift; sourceTree = ""; }; - FF0000042DUMMYID001234567 /* PiggyCardsConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/PiggyCardsConstants.swift; sourceTree = ""; }; + FF0000042DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/PiggyCardsConstants.swift; sourceTree = ""; }; FF0000062DUMMYID001234567 /* PiggyCardsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiggyCardsModels.swift; sourceTree = ""; }; FF0000092DUMMYID001234567 /* BarcodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScanner.swift; sourceTree = ""; }; GG0000032DUMMYID001234567 /* GeoRestrictionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRestrictionService.swift; sourceTree = ""; }; @@ -3652,17 +3665,9 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 51DFA7FB2FA8EAF2001EDB3A /* Components */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = Components; - sourceTree = ""; - }; + 51DC0001300000000000DC01 /* DashConnect */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DashConnect; sourceTree = ""; }; + 51DC0002300000000000DC02 /* DashConnect */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DashConnect; sourceTree = ""; }; + 51DFA7FB2FA8EAF2001EDB3A /* Components */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Components; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -3679,8 +3684,8 @@ buildActionMask = 2147483647; files = ( 510336ED2FEBB1B4002E0899 /* DashUIKit in Frameworks */, - DE3A167A235B79D705C0A962 /* BuildFile in Frameworks */, - AEAE60406265A73AFD573FD1 /* BuildFile in Frameworks */, + DE3A167A235B79D705C0A962 /* (null) in Frameworks */, + AEAE60406265A73AFD573FD1 /* (null) in Frameworks */, C95D147D8F600AF97EC17E99 /* SwiftDashSDK in Frameworks */, BC2677B2880883067AE18DC5 /* libPods-dashwallet.a in Frameworks */, ); @@ -3690,7 +3695,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 19BF3F10240A082811AAC1EB /* BuildFile in Frameworks */, + 19BF3F10240A082811AAC1EB /* (null) in Frameworks */, B059B6E200A57A699E6EED84 /* libPods-DashWalletTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4495,6 +4500,7 @@ 2A44313C22CF631E009BAF7F /* UI */ = { isa = PBXGroup; children = ( + 51DC0001300000000000DC01 /* DashConnect */, 7566F4882BB6CAD6005238D2 /* SwiftUI Components */, C943B34F2A40A54500AF23C5 /* DashPay */, 4751137228DAF27300223B77 /* Assembly */, @@ -4529,6 +4535,7 @@ 2A44313D22CF632F009BAF7F /* Models */ = { isa = PBXGroup; children = ( + 51DC0002300000000000DC02 /* DashConnect */, AA0002192BA0F58E00A1B309 /* Maya */, AA0010002CA0B10001A0B100 /* SwapKit */, AA0020012FA0000000000001 /* Swap */, @@ -6156,9 +6163,9 @@ 47AE8B9628BFACE800490F5E /* Model */ = { isa = PBXGroup; children = ( - 7545ED522DA91A2F0075F45C /* CTXConstants.swift */, - AA0000062DUMMYID001234567 /* GiftCardProvider.swift */, - FF0000042DUMMYID001234567 /* PiggyCardsConstants.swift */, + 7545ED522DA91A2F0075F45C /* DashSpend/CTXConstants.swift */, + AA0000062DUMMYID001234567 /* DashSpend/GiftCardProvider.swift */, + FF0000042DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift */, 47AE8B9A28BFAD2000490F5E /* Entites */, 47AE8B9928BFAD0B00490F5E /* DAO */, ); @@ -6178,15 +6185,15 @@ isa = PBXGroup; children = ( FF0000092DUMMYID001234567 /* BarcodeScanner.swift */, - 75A2F3022DE48C860046BE17 /* CTXSpendTokenService.swift */, - 754565C42DAA52A000DA4E8E /* CTXSpendAPI.swift */, - 754565C52DAA52A000DA4E8E /* CTXSpendEndpoint.swift */, - 754565C72DAA52A000DA4E8E /* CTXSpendRepository.swift */, - BB0000072DUMMYID001234567 /* DashSpendError.swift */, - BB0000082DUMMYID001234567 /* DashSpendRepository.swift */, - BB0000092DUMMYID001234567 /* DashSpendRepositoryFactory.swift */, + 75A2F3022DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift */, + 754565C42DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift */, + 754565C52DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift */, + 754565C72DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift */, + BB0000072DUMMYID001234567 /* DashSpend/DashSpendError.swift */, + BB0000082DUMMYID001234567 /* DashSpend/DashSpendRepository.swift */, + BB0000092DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift */, GG0000032DUMMYID001234567 /* GeoRestrictionService.swift */, - DD0000012DUMMYID001234567 /* PiggyCards */, + DD0000012DUMMYID001234567 /* DashSpend/PiggyCards */, 47AE8BAC28BFAE6700490F5E /* DWLocationManager.swift */, 47AE8BA828BFAE5800490F5E /* ExploreDatabaseSyncManager.swift */, ); @@ -7055,6 +7062,13 @@ 471DD1BE290AE9F000E030C8 /* DashWalletTests-Bridging-Header.h */, 2A2120EB22145CAE009906DC /* DWAmountInputValidatorTests.m */, 471DD1BB290AE30B00E030C8 /* String+DashWalletTests.swift */, + 2A7CAFFF191922CBF6075212 /* DashConnectUriTests.swift */, + 7C172F43568AE0A5AFDB5349 /* Secp256k1Tests.swift */, + AA03A0032FF3000000000003 /* KeyExchangeCryptoTests.swift */, + AA03A0042FF3000000000004 /* LoginKeyDerivationTests.swift */, + AA04A0032FF4000000000003 /* DashConnectDataSourceTests.swift */, + AA05A0032FF5000000000003 /* DashConnectStoreTests.swift */, + AA06A0032FF6000000000003 /* PlatformDashConnectDataSourceTests.swift */, 47976E492987772700612988 /* AmountObjectTests.swift */, A17EED0126C7000000000002 /* WalletWipeSerialExecutorTests.swift */, B7C072AA26C7000000000002 /* DWContestedNameStatusServiceTests.swift */, @@ -8532,7 +8546,7 @@ path = Components; sourceTree = ""; }; - DD0000012DUMMYID001234567 /* PiggyCards */ = { + DD0000012DUMMYID001234567 /* DashSpend/PiggyCards */ = { isa = PBXGroup; children = ( CC0000062DUMMYID001234567 /* PiggyCardsAPI.swift */, @@ -8636,6 +8650,8 @@ BA913BF91BD57E4D005A7C0E /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + 51DC0001300000000000DC01 /* DashConnect */, + 51DC0002300000000000DC02 /* DashConnect */, 51DFA7FB2FA8EAF2001EDB3A /* Components */, ); name = dashwallet; @@ -8740,6 +8756,8 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( + 51DC0001300000000000DC01 /* DashConnect */, + 51DC0002300000000000DC02 /* DashConnect */, 51DFA7FB2FA8EAF2001EDB3A /* Components */, ); name = dashpay; @@ -8844,7 +8862,7 @@ mainGroup = 75D5F3B5191EC270004AB296; packageReferences = ( 510336EB2FEBB1B4002E0899 /* XCRemoteSwiftPackageReference "DashUIKit" */, - 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "swift-sdk" */, + 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "../platform/packages/swift-sdk" */, ); productRefGroup = 75D5F3BF191EC270004AB296 /* Products */; projectDirPath = ""; @@ -9002,7 +9020,7 @@ C9D2C9272A320AA000D15901 /* SyncView.xib in Resources */, C9D2C9292A320AA000D15901 /* BackupInfoItemView.xib in Resources */, C9D2C92A2A320AA000D15901 /* explore.db in Resources */, - C9D2C92B2A320AA000D15901 /* BuildFile in Resources */, + C9D2C92B2A320AA000D15901 /* (null) in Resources */, C9D2C92C2A320AA000D15901 /* Coinbase.storyboard in Resources */, 5151C8C62F913BF100F0A604 /* Coinbase-Info.plist in Resources */, 5151C8C72F913BF100F0A604 /* ZenLedger-Info.plist in Resources */, @@ -9414,7 +9432,7 @@ A11E57A7000BC0DE000BB00B /* SyncingAlertView.swift in Sources */, 11B8449628F5B9F80082770C /* CrowdNodeResponse.swift in Sources */, 47AE8BB228BFF61A00490F5E /* FileManager+DashWallet.swift in Sources */, - 7545ED532DA91A2F0075F45C /* CTXConstants.swift in Sources */, + 7545ED532DA91A2F0075F45C /* DashSpend/CTXConstants.swift in Sources */, 47AE8BF228C1306000490F5E /* PointOfUseItemCell.swift in Sources */, C9F42F9F29DA82E5001BC549 /* PayableViewController.swift in Sources */, 75FFD6C12BF493A60032879E /* HomeViewController+SecureWalletDelegateImpl.swift in Sources */, @@ -9431,7 +9449,7 @@ 2A0C69DA2314727F001B8C90 /* UISpringTimingParameters+DWInit.m in Sources */, 47C661AD28F972BD00028A8D /* NumberKeyboardButton.swift in Sources */, 11AE3DD82997C599000856EE /* IsDefaultEmail.swift in Sources */, - 75A2F3042DE48C860046BE17 /* CTXSpendTokenService.swift in Sources */, + 75A2F3042DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift in Sources */, 2A9FFE812230FF4700956D5F /* DWFormTableViewController.m in Sources */, C909614D29EFF7D600002D82 /* WalletKeysOverviewModel.swift in Sources */, 4751137528DAF28800223B77 /* UIAssembly.swift in Sources */, @@ -9774,10 +9792,10 @@ AA00301B2FD000000000001B /* SwapBuyTransactionMatcher.swift in Sources */, AA0030092FD0000000000009 /* SwapOrdersDAO.swift in Sources */, AA00300F2FD000000000000F /* SwapOrderMetadataProvider.swift in Sources */, - AA0000042DUMMYID001234567 /* GiftCardProvider.swift in Sources */, - BB0000012DUMMYID001234567 /* DashSpendError.swift in Sources */, - BB0000032DUMMYID001234567 /* DashSpendRepository.swift in Sources */, - BB0000052DUMMYID001234567 /* DashSpendRepositoryFactory.swift in Sources */, + AA0000042DUMMYID001234567 /* DashSpend/GiftCardProvider.swift in Sources */, + BB0000012DUMMYID001234567 /* DashSpend/DashSpendError.swift in Sources */, + BB0000032DUMMYID001234567 /* DashSpend/DashSpendRepository.swift in Sources */, + BB0000052DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift in Sources */, GG0000012DUMMYID001234567 /* GeoRestrictionService.swift in Sources */, CC0000112DUMMYID001234567 /* PiggyCardsAPI.swift in Sources */, CC0000132DUMMYID001234567 /* PiggyCardsCache.swift in Sources */, @@ -9788,7 +9806,7 @@ 51A577482FBB3A6000FB4097 /* OrderPreviewViewModel.swift in Sources */, 5EAD111230A0000000A1B201 /* MayaAmountFormatter.swift in Sources */, EE0000042DUMMYID001234567 /* PercentageFormatter.swift in Sources */, - FF0000012DUMMYID001234567 /* PiggyCardsConstants.swift in Sources */, + FF0000012DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift in Sources */, FF0000052DUMMYID001234567 /* PiggyCardsModels.swift in Sources */, FF0000082DUMMYID001234567 /* BarcodeScanner.swift in Sources */, 2A8B9E6F2302A9C200FF8653 /* DWPasteboardAddressExtractor.m in Sources */, @@ -9806,12 +9824,12 @@ 2AD1CE8022DC92BF00C99324 /* NSString+DWTextSize.m in Sources */, 47AE8C0528C1F74A00490F5E /* PointOfUseListFiltersCell.swift in Sources */, 47AE8C1A28C6A21A00490F5E /* AllMerchantLocationsDataProvider.swift in Sources */, - 754565CC2DAA52A000DA4E8E /* CTXSpendAPI.swift in Sources */, + 754565CC2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift in Sources */, 51F0D10430BF98BA00B27E11 /* ArrowDownIcon.swift in Sources */, 516983C22FCAEDEB00BA91A2 /* XmarkIcon.swift in Sources */, 51F0D10130BF97A900B27E11 /* ExclamationIcon.swift in Sources */, - 754565CE2DAA52A000DA4E8E /* CTXSpendRepository.swift in Sources */, - 754565CF2DAA52A000DA4E8E /* CTXSpendEndpoint.swift in Sources */, + 754565CE2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift in Sources */, + 754565CF2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift in Sources */, C9F451E52A0B986E00825057 /* MainTabbarController.swift in Sources */, 47B30D78290BFCA60080C326 /* NumberFormatter+DashWallet.swift in Sources */, 47AF18082907B7880025803E /* BaseAmountModel.swift in Sources */, @@ -10197,9 +10215,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 47976E4B2987781A00612988 /* DWAmountInputValidator.m in Sources */, 2A2120EC22145CAE009906DC /* DWAmountInputValidatorTests.m in Sources */, 471DD1C0290AECE900E030C8 /* String+DashWalletTests.swift in Sources */, + 0438F3D117619B14594F95E6 /* DashConnectUriTests.swift in Sources */, + 8D66A1B0E97F5B50411765B4 /* Secp256k1Tests.swift in Sources */, + AA03A0012FF3000000000001 /* KeyExchangeCryptoTests.swift in Sources */, + AA03A0022FF3000000000002 /* LoginKeyDerivationTests.swift in Sources */, + AA04A0012FF4000000000001 /* DashConnectDataSourceTests.swift in Sources */, + AA05A0012FF5000000000001 /* DashConnectStoreTests.swift in Sources */, + AA06A0012FF6000000000001 /* PlatformDashConnectDataSourceTests.swift in Sources */, 47976E4A2987772700612988 /* AmountObjectTests.swift in Sources */, A17EED0126C7000000000001 /* WalletWipeSerialExecutorTests.swift in Sources */, B7C072AA26C7000000000001 /* DWContestedNameStatusServiceTests.swift in Sources */, @@ -10370,7 +10394,7 @@ 759AFDE32CC67E8E007072D2 /* VotingInfoScreen.swift in Sources */, C9D2C6EC2A320AA000D15901 /* DWModalInteractiveTransition.m in Sources */, C9D2C6F02A320AA000D15901 /* UIImage+Utils.m in Sources */, - 75A2F3032DE48C860046BE17 /* CTXSpendTokenService.swift in Sources */, + 75A2F3032DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift in Sources */, C9D2C6F22A320AA000D15901 /* DWSetupViewController.m in Sources */, C9D2C6F32A320AA000D15901 /* ExploreDatabaseConnection.swift in Sources */, C9D2C6F42A320AA000D15901 /* WithdrawalLimitsController.swift in Sources */, @@ -10442,7 +10466,7 @@ 75A8C1672AE5734A0042256E /* UsernameRequest.swift in Sources */, C9D2C70D2A320AA000D15901 /* CBAccount.swift in Sources */, C9D2C70E2A320AA000D15901 /* Types.swift in Sources */, - C9D2C70F2A320AA000D15901 /* BuildFile in Sources */, + C9D2C70F2A320AA000D15901 /* (null) in Sources */, C943B33B2A408CED00AF23C5 /* DWUploadAvatarChildView.m in Sources */, C9D2C7112A320AA000D15901 /* Coinbase.swift in Sources */, C9D2C7122A320AA000D15901 /* SyncModel.swift in Sources */, @@ -10469,7 +10493,7 @@ C9D2C7242A320AA000D15901 /* WKWebView+CrowdNode.swift in Sources */, C9D2C7252A320AA000D15901 /* SyncingActivityMonitor.swift in Sources */, C9D2C7262A320AA000D15901 /* EmptyUIView.swift in Sources */, - C9D2C7272A320AA000D15901 /* BuildFile in Sources */, + C9D2C7272A320AA000D15901 /* (null) in Sources */, C9D2C7282A320AA000D15901 /* DWQRScanModel.m in Sources */, C943B3292A408CED00AF23C5 /* DWExternalSourceViewController.m in Sources */, C9D2C72A2A320AA000D15901 /* SyncView.swift in Sources */, @@ -10535,7 +10559,7 @@ C956AF102A5B592E002FAB75 /* UIView+Dash.swift in Sources */, C9D2C74A2A320AA000D15901 /* ShortcutsView.swift in Sources */, C9D2C74B2A320AA000D15901 /* DWFormSectionModel.m in Sources */, - C9D2C74C2A320AA000D15901 /* BuildFile in Sources */, + C9D2C74C2A320AA000D15901 /* (null) in Sources */, C9D2C74D2A320AA000D15901 /* CoinbasePlaceBuyOrderResponse.swift in Sources */, C9D2C74E2A320AA000D15901 /* ExploreMapView.swift in Sources */, 40AB5A54235FDE2747ECE3F5 /* SwapConvertCardRow.swift in Sources */, @@ -10552,7 +10576,7 @@ C9D2C7552A320AA000D15901 /* DWPaymentInput.m in Sources */, C9D2C7572A320AA000D15901 /* OnlineAccountDetailsController.swift in Sources */, C9D2C7582A320AA000D15901 /* UIApplication+DashWallet.swift in Sources */, - 7545ED542DA91A2F0075F45C /* CTXConstants.swift in Sources */, + 7545ED542DA91A2F0075F45C /* DashSpend/CTXConstants.swift in Sources */, C9D2C7592A320AA000D15901 /* OnlineAccountInfoController.swift in Sources */, C9D2C75A2A320AA000D15901 /* PaymentController.swift in Sources */, C9D2C75B2A320AA000D15901 /* CALayer+MBAnimationPersistence.m in Sources */, @@ -10574,7 +10598,7 @@ AA0031032FD0000000000013 /* IconName+DashIconSource.swift in Sources */, 754C27C72CC3C0B900BA7B9F /* UsernamePrefs.swift in Sources */, C9D2C7672A320AA000D15901 /* CoinbaseExchangeRateResponse.swift in Sources */, - C9D2C7692A320AA000D15901 /* BuildFile in Sources */, + C9D2C7692A320AA000D15901 /* (null) in Sources */, C9D2C76A2A320AA000D15901 /* CrowdNodePortalItem.swift in Sources */, C9D2C76B2A320AA000D15901 /* BaseViewController.swift in Sources */, C9D2C76C2A320AA000D15901 /* AccountListController.swift in Sources */, @@ -10649,7 +10673,7 @@ C9D2C7A72A320AA000D15901 /* ActionButtonViewController.swift in Sources */, C9D2C7A82A320AA000D15901 /* BuySellServiceItemCell.swift in Sources */, C956AF122A5B5949002FAB75 /* PasteboardContentView.swift in Sources */, - C9D2C7AA2A320AA000D15901 /* BuildFile in Sources */, + C9D2C7AA2A320AA000D15901 /* (null) in Sources */, C9D2C7AB2A320AA000D15901 /* DWBaseFormTableViewCell.m in Sources */, C9D2C7AC2A320AA000D15901 /* AccountCell.swift in Sources */, 7566F4842BB6949E005238D2 /* ToolsMenuScreen.swift in Sources */, @@ -10658,7 +10682,7 @@ C943B3392A408CED00AF23C5 /* DWUploadAvatarViewController.m in Sources */, 759609242C455B2000F3BF04 /* SendIntro.swift in Sources */, C9D2C7B12A320AA000D15901 /* CoinbaseBaseIDForCurrencyResponse.swift in Sources */, - C9D2C7B22A320AA000D15901 /* BuildFile in Sources */, + C9D2C7B22A320AA000D15901 /* (null) in Sources */, C9D2C7B32A320AA000D15901 /* SpecifyAmountViewController.swift in Sources */, 756557B62CE84FFA0060348D /* FeeInfo.swift in Sources */, C943B5922A40ED7B00AF23C5 /* DWTextField.m in Sources */, @@ -10677,7 +10701,7 @@ C956AF212A5C33E5002FAB75 /* DWButton.m in Sources */, C9D2C7C02A320AA000D15901 /* HomeHeaderModel.swift in Sources */, C943B5942A40ED7B00AF23C5 /* DWPlanetarySystemView.m in Sources */, - C9D2C7C22A320AA000D15901 /* BuildFile in Sources */, + C9D2C7C22A320AA000D15901 /* (null) in Sources */, C9D2C7C32A320AA000D15901 /* DWDPRegistrationStatus.m in Sources */, C9D2C7C42A320AA000D15901 /* CurrencyExchanger_Objc.m in Sources */, 752F81982E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */, @@ -10691,7 +10715,7 @@ C9D2C7C92A320AA000D15901 /* BuyDashViewController.swift in Sources */, C956AF0D2A5B592E002FAB75 /* TappableLabel.swift in Sources */, C9D2C7CA2A320AA000D15901 /* DWModalDismissalAnimation.m in Sources */, - C9D2C7CB2A320AA000D15901 /* BuildFile in Sources */, + C9D2C7CB2A320AA000D15901 /* (null) in Sources */, C9D2C7CD2A320AA000D15901 /* UIViewController+DashWallet.swift in Sources */, 75D5D5332CC928630049ED7B /* UIHostingController+DashWallet.swift in Sources */, C9D2C7CE2A320AA000D15901 /* CrowdNodeCell.swift in Sources */, @@ -10722,10 +10746,10 @@ AA00301C2FD000000000001C /* SwapBuyTransactionMatcher.swift in Sources */, AA00300A2FD000000000000A /* SwapOrdersDAO.swift in Sources */, AA0030102FD0000000000010 /* SwapOrderMetadataProvider.swift in Sources */, - AA0000052DUMMYID001234567 /* GiftCardProvider.swift in Sources */, - BB0000022DUMMYID001234567 /* DashSpendError.swift in Sources */, - BB0000042DUMMYID001234567 /* DashSpendRepository.swift in Sources */, - BB0000062DUMMYID001234567 /* DashSpendRepositoryFactory.swift in Sources */, + AA0000052DUMMYID001234567 /* DashSpend/GiftCardProvider.swift in Sources */, + BB0000022DUMMYID001234567 /* DashSpend/DashSpendError.swift in Sources */, + BB0000042DUMMYID001234567 /* DashSpend/DashSpendRepository.swift in Sources */, + BB0000062DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift in Sources */, GG0000022DUMMYID001234567 /* GeoRestrictionService.swift in Sources */, CC0000122DUMMYID001234567 /* PiggyCardsAPI.swift in Sources */, CC0000142DUMMYID001234567 /* PiggyCardsCache.swift in Sources */, @@ -10734,7 +10758,7 @@ CC0000202DUMMYID001234567 /* PiggyCardsTokenService.swift in Sources */, EE0000022DUMMYID001234567 /* POIDetailsViewModel.swift in Sources */, EE0000062DUMMYID001234567 /* PercentageFormatter.swift in Sources */, - FF0000022DUMMYID001234567 /* PiggyCardsConstants.swift in Sources */, + FF0000022DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift in Sources */, FF0000072DUMMYID001234567 /* PiggyCardsModels.swift in Sources */, FF0000102DUMMYID001234567 /* BarcodeScanner.swift in Sources */, C9D2C7E32A320AA000D15901 /* PointOfUseListSegmentedCell.swift in Sources */, @@ -10850,7 +10874,7 @@ C9D2C8482A320AA000D15901 /* CoinbaseTransactionsRequest.swift in Sources */, C943B3372A408CED00AF23C5 /* DWEditProfileTextFieldCell.m in Sources */, C9D2C8492A320AA000D15901 /* DWAppRootViewController.m in Sources */, - C9D2C84A2A320AA000D15901 /* BuildFile in Sources */, + C9D2C84A2A320AA000D15901 /* (null) in Sources */, 7513DA882AB175E0005D55F6 /* TopperViewModel.swift in Sources */, C9D2C84B2A320AA000D15901 /* UITableView+DashWallet.swift in Sources */, 10853C84CC9043928BA2205A /* TransferAmountViewModel.swift in Sources */, @@ -10889,7 +10913,7 @@ C9D2C8622A320AA000D15901 /* BackupInfoViewController.swift in Sources */, C9D2C8652A320AA000D15901 /* DWCaptureSessionManager.m in Sources */, 7502A4872AE401EF00ACDDD3 /* UsernameVotingViewController.swift in Sources */, - C9D2C86A2A320AA000D15901 /* BuildFile in Sources */, + C9D2C86A2A320AA000D15901 /* (null) in Sources */, C9D2C86B2A320AA000D15901 /* AmountPreviewView.swift in Sources */, 75CDD7812C0898E400F433D2 /* Shape.swift in Sources */, 756FE7082CDCBBC800E6C195 /* UsernameValidationRuleResult.swift in Sources */, @@ -10940,7 +10964,7 @@ 75CDD7872C08D61300F433D2 /* DashAmount.swift in Sources */, C9D2C8972A320AA000D15901 /* RatesProvider.swift in Sources */, C9D2C8982A320AA000D15901 /* CBAuth.swift in Sources */, - C9D2C8992A320AA000D15901 /* BuildFile in Sources */, + C9D2C8992A320AA000D15901 /* (null) in Sources */, C9D2C89A2A320AA000D15901 /* CoinbaseCreateAddressesRequest.swift in Sources */, C943B3382A408CED00AF23C5 /* DWEditProfileAvatarView.m in Sources */, C943B5582A40DA3700AF23C5 /* DWFullScreenModalControllerViewController.m in Sources */, @@ -10970,9 +10994,9 @@ 757422222DF87B5200CB0175 /* MerchantFiltersView.swift in Sources */, C943B5382A40A65B00AF23C5 /* DWScrollingViewController.m in Sources */, C9D2C8B22A320AA000D15901 /* CoinbaseAccountAddress.swift in Sources */, - 754565C82DAA52A000DA4E8E /* CTXSpendAPI.swift in Sources */, - 754565CA2DAA52A000DA4E8E /* CTXSpendRepository.swift in Sources */, - 754565CB2DAA52A000DA4E8E /* CTXSpendEndpoint.swift in Sources */, + 754565C82DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift in Sources */, + 754565CA2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift in Sources */, + 754565CB2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift in Sources */, C930784E2A6AD59E00906E4B /* ConfirmPaymentModel.swift in Sources */, C9D2C8B42A320AA000D15901 /* CrowdNodeTransferViewController.swift in Sources */, C9D2C8B62A320AA000D15901 /* CBAuthInterop.swift in Sources */, @@ -11014,7 +11038,7 @@ C9D2C8DD2A320AA000D15901 /* AtmDataProvider.swift in Sources */, C9D2C8DE2A320AA000D15901 /* GettingStartedViewController.swift in Sources */, C9D2C8DF2A320AA000D15901 /* NetworkUnavailableView.swift in Sources */, - C9D2C8E02A320AA000D15901 /* BuildFile in Sources */, + C9D2C8E02A320AA000D15901 /* (null) in Sources */, 751B61C32ADFF9AE00D1C2EF /* UpholdPortalModel.swift in Sources */, C9D2C8E12A320AA000D15901 /* DWLockScreenViewController.m in Sources */, C9D2C8E22A320AA000D15901 /* UpholdTransferViewController.swift in Sources */, @@ -11203,7 +11227,7 @@ }; 75D5F3EB191EC270004AB296 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 75D5F3BD191EC270004AB296 /* dashwallet */; + target = C9D2C68A2A320AA000D15901 /* dashpay */; targetProxy = 75D5F3EA191EC270004AB296 /* PBXContainerItemProxy */; }; BA913BED1BD57E4D005A7C0E /* PBXTargetDependency */ = { @@ -11843,7 +11867,7 @@ baseConfigurationReference = 41D6F206BA803FDFA21B31F5 /* Pods-DashWalletTests.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashpay.app/dashpay"; CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -11875,7 +11899,7 @@ baseConfigurationReference = 1AF20341A5E313C9D309BF39 /* Pods-DashWalletTests.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashpay.app/dashpay"; CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -12786,7 +12810,7 @@ baseConfigurationReference = F5121ED6CEEEAEDBE95414EE /* Pods-DashWalletTests.testflight.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashpay.app/dashpay"; CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -13082,7 +13106,7 @@ baseConfigurationReference = 5CBC0F417B9E8DE631F9DE2A /* Pods-DashWalletTests.testnet.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashpay.app/dashpay"; CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -13252,7 +13276,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "swift-sdk" */ = { + 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "../platform/packages/swift-sdk" */ = { isa = XCLocalSwiftPackageReference; relativePath = "../platform/packages/swift-sdk"; }; @@ -13263,7 +13287,7 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/dashpay/DashUIKit"; requirement = { - branch = master; + branch = "fix/textfield-prompt-type"; kind = branch; }; }; @@ -13272,12 +13296,12 @@ /* Begin XCSwiftPackageProductDependency section */ 3A1C87FAE41649975F6D9814 /* SwiftDashSDK */ = { isa = XCSwiftPackageProductDependency; - package = 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "swift-sdk" */; + package = 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "../platform/packages/swift-sdk" */; productName = SwiftDashSDK; }; 3AEF30CC2C8105771448004C /* SwiftDashSDK */ = { isa = XCSwiftPackageProductDependency; - package = 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "swift-sdk" */; + package = 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "../platform/packages/swift-sdk" */; productName = SwiftDashSDK; }; 510336EC2FEBB1B4002E0899 /* DashUIKit */ = { diff --git a/DashWallet.xcodeproj/xcshareddata/xcschemes/dashwallet-dashpay.xcscheme b/DashWallet.xcodeproj/xcshareddata/xcschemes/dashwallet-dashpay.xcscheme index 80f3f30f7..5f48f9b7e 100644 --- a/DashWallet.xcodeproj/xcshareddata/xcschemes/dashwallet-dashpay.xcscheme +++ b/DashWallet.xcodeproj/xcshareddata/xcschemes/dashwallet-dashpay.xcscheme @@ -50,26 +50,6 @@ ReferencedContainer = "container:DashWallet.xcodeproj"> - - - - - - - - + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/Contents.json new file mode 100644 index 000000000..5ff20cb2d --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "icon.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/icon.svg b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/icon.svg new file mode 100644 index 000000000..532118952 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/Contents.json new file mode 100644 index 000000000..5ff20cb2d --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "icon.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/icon.svg b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/icon.svg new file mode 100644 index 000000000..ec20fc517 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/Contents.json new file mode 100644 index 000000000..b78af8e5c --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "menu-connections.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "menu-connections@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "menu-connections@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections.png b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections.png new file mode 100644 index 000000000..1e0b8f583 Binary files /dev/null and b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections.png differ diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections@2x.png b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections@2x.png new file mode 100644 index 000000000..144d7c85b Binary files /dev/null and b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections@2x.png differ diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections@3x.png b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections@3x.png new file mode 100644 index 000000000..6f40b11b2 Binary files /dev/null and b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections@3x.png differ diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/Contents.json new file mode 100644 index 000000000..5ff20cb2d --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "icon.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/icon.svg b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/icon.svg new file mode 100644 index 000000000..3163b039e --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/DashWallet/Sources/Infrastructure/Networking/NetworkReachability.swift b/DashWallet/Sources/Infrastructure/Networking/NetworkReachability.swift index 9479ad1e2..cd18b8810 100644 --- a/DashWallet/Sources/Infrastructure/Networking/NetworkReachability.swift +++ b/DashWallet/Sources/Infrastructure/Networking/NetworkReachability.swift @@ -59,17 +59,15 @@ final class NetworkReachability: NSObject { hasReceivedFirstPath = false lock.unlock() - // Block until the first path update lands so callers observe real - // state the moment this method returns — matches `SCNetworkReachability`'s - // synchronous contract that `DSReachabilityManager` relied on. - let firstUpdate = DispatchSemaphore(value: 0) m.pathUpdateHandler = { [weak self] path in - if self?.handlePathUpdate(path) == true { - firstUpdate.signal() - } + self?.handlePathUpdate(path) } m.start(queue: queue) - _ = firstUpdate.wait(timeout: .now() + .milliseconds(200)) + + // Callers read `isReachable` the moment this returns, so the state is + // seeded from the path the monitor already holds instead of parking the + // caller until the utility-QoS queue delivers its first callback. + handlePathUpdate(m.currentPath) } @objc func stopMonitoring() { @@ -80,16 +78,15 @@ final class NetworkReachability: NSObject { m?.cancel() } - /// Returns `true` on the first path update after `startMonitoring()`. - @discardableResult - private func handlePathUpdate(_ path: NWPath) -> Bool { + /// Updates the shared reachability snapshot from the latest path and marks + /// whether the monitor has observed at least one path since it started. + private func handlePathUpdate(_ path: NWPath) { let reachable = path.status == .satisfied let wifi = reachable && path.usesInterfaceType(.wifi) lock.lock() _isReachable = reachable _isReachableViaWiFi = wifi - let wasFirst = !hasReceivedFirstPath hasReceivedFirstPath = true lock.unlock() @@ -99,6 +96,5 @@ final class NetworkReachability: NSObject { object: self ) } - return wasFirst } } diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift new file mode 100644 index 000000000..b628d6f95 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift @@ -0,0 +1,278 @@ +// +// DashConnectDataSource.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +protocol DashConnectDataSource { + var connections: AnyPublisher<[DAppConnection], Never> { get } + + func parseQR(_ content: String) async throws -> DashConnectQr + func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest + func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection + func completeKeyRegistration(_ request: DashStRequest) async throws + func disconnect(id: String) async + func remove(id: String) async +} + +enum DashConnectMockError: LocalizedError, Equatable { + case parseFailed + case approveFailed + case notDashConnectQrCode + case unsupportedNetwork(expected: DashConnectNetwork, actual: DashConnectNetwork) + case keyRegistrationNotSupported + + var errorDescription: String? { + switch self { + case .parseFailed: + return "Invalid QR payload" + case .approveFailed: + return "Approval failed" + case .notDashConnectQrCode: + return "This QR code is not a DashConnect QR code." + case let .unsupportedNetwork(expected, actual): + return "This DashConnect QR is for \(Self.displayName(for: actual)), but this wallet currently supports \(Self.displayName(for: expected)) only." + case .keyRegistrationNotSupported: + return "Key registration is not supported by the mock." + } + } + + private static func displayName(for network: DashConnectNetwork) -> String { + switch network { + case .mainnet: + return "Mainnet" + case .testnet: + return "Testnet" + case .devnet: + return "Devnet" + } + } +} + +final class MockDashConnectDataSource: DashConnectDataSource { + static let sampleLoginRequest = DashKeyRequest( + appEphemeralPubKey: Data(hex: "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f")!, + // Yappr's real testnet contract, so the sample resolves to Yappr branding and + // matches `sample(_:)`'s id. + contractId: Data([ + 0xc8, 0xb1, 0x02, 0xb5, 0x6e, 0xa7, 0xbb, 0xac, + 0xf7, 0x72, 0xd8, 0x36, 0x23, 0xf8, 0xd1, 0x8c, + 0x53, 0xae, 0xf2, 0x07, 0x66, 0x59, 0x9a, 0xc6, + 0x82, 0xb6, 0xa0, 0x19, 0xf2, 0x9d, 0x35, 0x3e + ]), + label: "Login to Yappr", + network: .testnet + ) + static let sampleLoginQRCode = DashConnectSampleQR.keyUri(for: sampleLoginRequest) + static let sampleRequest = ConnectionRequest(loginRequest: sampleLoginRequest) + + static func sample(_ status: ConnectionStatus) -> DAppConnection { + DAppConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + name: "Yappr", + url: "yappr.io", + status: status, + updatedAt: Date(timeIntervalSince1970: 1_773_132_300) + ) + } + + #if DEBUG + static func previewMixed() -> MockDashConnectDataSource { + MockDashConnectDataSource(initial: [ + sample(.approved), + DAppConnection( + id: "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", + name: "DashGet", + url: "dashget.app", + status: .active, + updatedAt: Date(timeIntervalSince1970: 1_773_218_700) + ), + ]) + } + #endif + + var shouldFailNextParse = false + var shouldFailNextApprove = false + + private let supportedNetwork: DashConnectNetwork + private let store: any DashConnectStore + private let subject: CurrentValueSubject<[DAppConnection], Never> + + init( + initial: [DAppConnection] = [], + supportedNetwork: DashConnectNetwork = .testnet, + store: (any DashConnectStore)? = nil + ) { + self.supportedNetwork = supportedNetwork + self.store = store ?? UserDefaultsDashConnectStore(network: supportedNetwork) + let restored = initial.isEmpty ? self.store.load() : initial + self.subject = CurrentValueSubject(restored) + } + + var connections: AnyPublisher<[DAppConnection], Never> { + subject.eraseToAnyPublisher() + } + + var connectionsSnapshot: [DAppConnection] { + subject.value + } + + func parseQR(_ content: String) async throws -> DashConnectQr { + if shouldFailNextParse { + shouldFailNextParse = false + throw DashConnectMockError.parseFailed + } + + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + + if DashConnectUri.isKeyUri(trimmed) { + let request = try DashConnectUri.parseKeyRequest(trimmed) + try validateNetwork(request.network) + return .login(request) + } + + if DashConnectUri.isStUri(trimmed) { + let request = try DashConnectUri.parseStRequest(trimmed) + try validateNetwork(request.network) + return .keyRegistration(request) + } + + throw DashConnectMockError.notDashConnectQrCode + } + + func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection { + try await Task.sleep(nanoseconds: 400_000_000) + + if shouldFailNextApprove { + shouldFailNextApprove = false + throw DashConnectMockError.approveFailed + } + + let connectionRequest = await makeConnectionRequest(from: request) + let connection = DAppConnection( + id: connectionRequest.appContractId, + name: connectionRequest.appLabel.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : connectionRequest.appLabel, + url: connectionRequest.appUrl, + status: .approved, + updatedAt: Date() + ) + + var current = subject.value.filter { $0.id != connection.id } + current.append(connection) + persistAndSend(current) + return connection + } + + func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest { + let fallback = ConnectionRequest(loginRequest: loginRequest) + return ConnectionRequest( + loginRequest: loginRequest, + appLabel: fallback.appLabel, + appUrl: fallback.appUrl, + walletUsername: nil, + walletIdentityId: nil, + existingConnection: existingConnection(for: fallback.appContractId) + ) + } + + func completeKeyRegistration(_ request: DashStRequest) async throws { + try await Task.sleep(nanoseconds: 400_000_000) + throw DashConnectMockError.keyRegistrationNotSupported + } + + func disconnect(id: String) async { + let disconnectedAt = Date() + persistAndSend(subject.value.map { connection in + guard connection.id == id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .approved, + updatedAt: disconnectedAt + ) + }) + } + + func remove(id: String) async { + persistAndSend(subject.value.filter { $0.id != id }) + } + + private func validateNetwork(_ network: DashConnectNetwork) throws { + guard network == supportedNetwork else { + throw DashConnectMockError.unsupportedNetwork(expected: supportedNetwork, actual: network) + } + } + + private func persistAndSend(_ connections: [DAppConnection]) { + let sorted = connections.sorted(by: { $0.updatedAt > $1.updatedAt }) + store.save(sorted) + subject.send(sorted) + } + + private func existingConnection(for contractId: String) -> DAppConnection? { + subject.value.first { $0.id == contractId } + } +} + +private enum DashConnectSampleQR { + static func keyUri(for request: DashKeyRequest) -> String { + let labelData = Data(request.label.utf8) + let payload = Data([0x01]) + + request.appEphemeralPubKey + + request.contractId + + Data([UInt8(labelData.count)]) + + labelData + return "dash-key:\(base58Encode(payload))?n=\(request.network.rawValue)&v=1" + } + + private static func base58Encode(_ data: Data) -> String { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + let bytes = [UInt8](data) + var leadingZeros = 0 + while leadingZeros < bytes.count, bytes[leadingZeros] == 0 { + leadingZeros += 1 + } + + let size = (bytes.count - leadingZeros) * 138 / 100 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< bytes.count { + var carry = Int(bytes[i]) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 256 * Int(buffer[j]) + buffer[j] = UInt8(carry % 58) + carry /= 58 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = [UInt8]() + result.reserveCapacity(leadingZeros + size - start) + result.append(contentsOf: repeatElement(alphabet[0], count: leadingZeros)) + for i in start ..< size { + result.append(alphabet[Int(buffer[i])]) + } + return String(decoding: result, as: UTF8.self) + } +} diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift new file mode 100644 index 000000000..b42100950 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift @@ -0,0 +1,113 @@ +// +// DashConnectModels.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftDashSDK + +enum DashConnectQr: Equatable { + case login(DashKeyRequest) + case keyRegistration(DashStRequest) +} + +/// Lifecycle of an app connection. +/// +/// `approved` means the wallet knows about the app, but it may not be ready to +/// sign in yet: either the required login keys are not on the identity yet, or +/// the user locally turned the connection off from the wallet UI. +/// +/// `active` means the app's login keys are registered on the identity and the +/// wallet has already published the matching `loginKeyResponse`. +enum ConnectionStatus: String, Codable, CaseIterable { + case approved + case active + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + guard let status = ConnectionStatus(rawValue: rawValue) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unknown DashConnect status: \(rawValue)" + ) + } + self = status + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// A connected app identified by its stable contract id. +struct DAppConnection: Identifiable, Equatable, Hashable { + let id: String + let name: String + let url: String + let status: ConnectionStatus + let updatedAt: Date +} + +/// A pending request derived from a scanned login QR code. +struct ConnectionRequest: Equatable { + let appLabel: String + let appUrl: String + let appContractId: String + let walletUsername: String? + let walletIdentityId: String? + let existingConnection: DAppConnection? +} + +extension ConnectionRequest { + /// Declaring an initializer inside the struct body would suppress the memberwise one, + /// so this convenience lives in an extension. + init(loginRequest: DashKeyRequest) { + let contractId = loginRequest.contractId.toBase58String() + let branding = DashConnectFallbackAppMetadata.resolve( + contractId: contractId, + unauthenticatedLabel: loginRequest.label + ) + self.init( + appLabel: branding.name, + appUrl: branding.url, + appContractId: contractId, + walletUsername: nil, + walletIdentityId: nil, + existingConnection: nil + ) + } + + init( + loginRequest: DashKeyRequest, + appLabel: String, + appUrl: String, + walletUsername: String?, + walletIdentityId: String?, + existingConnection: DAppConnection? + ) { + self.init( + appLabel: appLabel.trimmingCharacters(in: .whitespacesAndNewlines), + appUrl: appUrl.trimmingCharacters(in: .whitespacesAndNewlines), + appContractId: loginRequest.contractId.toBase58String(), + walletUsername: walletUsername, + walletIdentityId: walletIdentityId, + existingConnection: existingConnection + ) + } +} diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift b/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift new file mode 100644 index 000000000..5c7f2d59f --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift @@ -0,0 +1,174 @@ +// +// DashConnectStore.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol DashConnectStore { + func load() -> [DAppConnection] + func save(_ connections: [DAppConnection]) +} + +final class UserDefaultsDashConnectStore: DashConnectStore { + private let defaults: UserDefaults + private let network: DashConnectNetwork + private let walletIdHexProvider: () -> String? + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init( + defaults: UserDefaults = .standard, + network: DashConnectNetwork, + walletIdHexProvider: @escaping () -> String? = { + WalletEnvironment.activeWalletIdHex as String? + } + ) { + self.defaults = defaults + self.network = network + self.walletIdHexProvider = walletIdHexProvider + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + self.decoder = decoder + } + + var storageKey: String { + let walletScope = walletIdHexProvider()?.trimmingCharacters(in: .whitespacesAndNewlines) + let walletSuffix = (walletScope?.isEmpty == false) ? walletScope! : "no-wallet" + return "dashconnect.connections.v1.\(network.rawValue).\(walletSuffix)" + } + + func load() -> [DAppConnection] { + guard let data = defaults.data(forKey: storageKey) else { return [] } + guard let rawRows = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { + logDrop("stored value is not a JSON array") + return [] + } + + var restored: [DAppConnection] = [] + restored.reserveCapacity(rawRows.count) + + for rawRow in rawRows { + guard JSONSerialization.isValidJSONObject(rawRow), + let rowData = try? JSONSerialization.data(withJSONObject: rawRow), + let row = try? decoder.decode(StoredConnection.self, from: rowData) else { + logDrop("row could not be decoded") + continue + } + + guard let contractIdBytes = Self.base58Decode(row.contractId), + contractIdBytes.count == 32 else { + logDrop("row has invalid contractId Base58") + continue + } + + restored.append( + DAppConnection( + id: row.contractId, + name: row.label, + url: row.url, + status: row.status, + updatedAt: row.updatedAt + ) + ) + } + + return restored + } + + func save(_ connections: [DAppConnection]) { + guard !connections.isEmpty else { + defaults.removeObject(forKey: storageKey) + return + } + + let rows = connections.map { + StoredConnection( + contractId: $0.id, + label: $0.name, + url: $0.url, + status: $0.status, + updatedAt: $0.updatedAt + ) + } + + do { + defaults.set(try encoder.encode(rows), forKey: storageKey) + } catch { + NSLog("DashConnectStore: failed to encode connections for %@: %@", storageKey, error.localizedDescription) + } + } + + private func logDrop(_ reason: String) { + NSLog("DashConnectStore: dropping stored row for %@: %@", storageKey, reason) + } + + private struct StoredConnection: Codable { + let contractId: String + let label: String + let url: String + let status: ConnectionStatus + let updatedAt: Date + } + + private static func base58Decode(_ string: String) -> Data? { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + let input = Array(string.utf8) + guard !input.isEmpty else { return nil } + + var reverse = [Int8](repeating: -1, count: 128) + for (index, byte) in alphabet.enumerated() { + reverse[Int(byte)] = Int8(index) + } + + var leadingZeros = 0 + while leadingZeros < input.count, input[leadingZeros] == alphabet[0] { + leadingZeros += 1 + } + + let size = (input.count - leadingZeros) * 733 / 1000 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< input.count { + let byte = input[i] + guard byte < 128 else { return nil } + let digit = reverse[Int(byte)] + guard digit >= 0 else { return nil } + + var carry = Int(digit) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 58 * Int(buffer[j]) + buffer[j] = UInt8(carry % 256) + carry /= 256 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = Data(repeating: 0, count: leadingZeros) + result.append(contentsOf: buffer[start...]) + return result + } +} diff --git a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift new file mode 100644 index 000000000..0b21f0a66 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift @@ -0,0 +1,1103 @@ +// +// PlatformDashConnectDataSource.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import OSLog +import Security +import SwiftData +import SwiftDashSDK + +struct DashConnectAppMetadata: Equatable { + let name: String + let url: String +} + +enum DashConnectFallbackAppMetadata { + // Fallback-only branding keyed by the trustworthy contract id, not the spoofable QR label. + private static let knownApps: [String: DashConnectAppMetadata] = [ + "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F": .init(name: "Yappr", url: "yap.pr") + ] + + static func resolve(contractId: String, unauthenticatedLabel: String) -> DashConnectAppMetadata { + if let known = knownApps[contractId] { + return known + } + + return DashConnectAppMetadata( + name: unauthenticatedLabel.trimmingCharacters(in: .whitespacesAndNewlines), + url: "" + ) + } +} + +/// `Equatable` so tests can assert which failure occurred rather than matching +/// on its message. Matches `DashConnectMockError`, which is already declared +/// the same way; every associated value here is itself `Equatable`, so the +/// conformance is synthesised. +enum DashConnectPlatformError: LocalizedError, Equatable { + case noWallet + case noIdentity + case noModelContainer + case noSDK + case unsupportedRuntimeNetwork(Network) + case unsupportedRequestNetwork(expected: DashConnectNetwork, actual: DashConnectNetwork) + case authorizationCancelled + case authorizationFailed + case randomGenerationFailed(OSStatus) + case invalidHash160 + case noAuthenticationKey + case existingDocumentLookupFailed + case noApprovedConnectionAwaitingKeyRegistration + case keyRegistrationWrongIdentity + case keyRegistrationUnexpectedMutation + case keyRegistrationMismatchedDerivedKey(KeyPurpose) + + var errorDescription: String? { + switch self { + case .noWallet: + return "DashConnect requires an active SwiftDashSDK wallet." + case .noIdentity: + return "DashConnect requires a registered Platform identity." + case .noModelContainer: + return "DashConnect requires the SwiftData model container." + case .noSDK: + return "DashConnect requires the SwiftDashSDK runtime." + case .unsupportedRuntimeNetwork(let network): + return "DashConnect Platform publish is testnet-only right now, but the app runtime is on \(Self.displayName(for: network))." + case let .unsupportedRequestNetwork(expected, actual): + return "This DashConnect QR is for \(Self.displayName(for: actual)), but this wallet currently supports \(Self.displayName(for: expected)) only." + case .authorizationCancelled: + return NSLocalizedString("Authentication cancelled", comment: "DashConnect") + case .authorizationFailed: + return NSLocalizedString("Authentication failed", comment: "DashConnect") + case .randomGenerationFailed(let status): + return "Failed to generate secure random bytes for DashConnect (\(status))." + case .invalidHash160: + return "Failed to compute the app ephemeral public key hash." + case .noAuthenticationKey: + return "No enabled AUTHENTICATION ECDSA key is available for this identity." + case .existingDocumentLookupFailed: + return "The existing loginKeyResponse document could not be located for replacement." + case .noApprovedConnectionAwaitingKeyRegistration: + return "There is no login awaiting key registration — scan the app's login QR first." + case .keyRegistrationWrongIdentity: + return "The scanned key-registration transition targets a different identity." + case .keyRegistrationUnexpectedMutation: + return "The scanned key-registration transition does more than add the expected login keys." + case .keyRegistrationMismatchedDerivedKey(let purpose): + return "The scanned key-registration transition adds a \(purpose.name) key we did not derive." + } + } + + private static func displayName(for network: DashConnectNetwork) -> String { + switch network { + case .mainnet: + return "Mainnet" + case .testnet: + return "Testnet" + case .devnet: + return "Devnet" + } + } + + private static func displayName(for network: Network) -> String { + switch network { + case .mainnet: + return "Mainnet" + case .testnet: + return "Testnet" + case .devnet: + return "Devnet" + case .regtest: + return "Regtest" + } + } +} + +struct DashConnectLoginKeyResponseDraft: Equatable { + let properties: [String: AnyHashable] + let walletEphemeralPublicKey: Data + let encryptedPayload: Data +} + +struct DashConnectKeyRegistrationKey: Equatable { + let keyId: UInt32 + let keyType: KeyType + let purpose: KeyPurpose + let securityLevel: SecurityLevel + let publicKeyData: Data + let contractBounds: ContractBounds? +} + +struct DashConnectKeyRegistrationTransition: Equatable { + let identityId: Data + let addPublicKeys: [DashConnectKeyRegistrationKey] + let disablePublicKeyIds: [UInt32] +} + +/// The two keys a `dash-st:` transition was allowed to add, once every check +/// passed, paired with the app contract id they were derived against. +struct DashConnectKeyRegistrationValidationResult: Equatable { + let appContractId: Data + let authenticationKey: DashConnectKeyRegistrationKey + let encryptionKey: DashConnectKeyRegistrationKey +} + +private struct DashConnectKeyRegistrationDerivedMaterial { + var loginKey: Data + var authenticationPrivateKey: Data + let authenticationPublicKeyHash160: Data + var encryptionPrivateKey: Data + let encryptionPublicKey: Data +} + +protocol DashConnectKeyRegistrationParsing { + func parse(_ transitionBytes: Data) throws -> DashConnectKeyRegistrationTransition +} + +struct PlatformWalletDashConnectKeyRegistrationParser: DashConnectKeyRegistrationParsing { + private let parseTransition: (Data) throws -> ManagedPlatformWallet.ParsedIdentityUpdateTransition + + init( + parseTransition: @escaping (Data) throws -> ManagedPlatformWallet.ParsedIdentityUpdateTransition = { bytes in + try MainActor.assumeIsolated { + guard let wallet = SwiftDashSDKHost.shared.wallet else { + throw DashConnectPlatformError.noWallet + } + return try wallet.parseIdentityUpdateTransition(bytes) + } + } + ) { + self.parseTransition = parseTransition + } + + func parse(_ transitionBytes: Data) throws -> DashConnectKeyRegistrationTransition { + let parsed = try parseTransition(transitionBytes) + + return DashConnectKeyRegistrationTransition( + identityId: parsed.identityId, + addPublicKeys: parsed.addPublicKeys.map { key in + DashConnectKeyRegistrationKey( + keyId: key.keyId, + keyType: key.keyType, + purpose: key.purpose, + securityLevel: key.securityLevel, + publicKeyData: key.pubkeyBytes, + contractBounds: key.contractBounds.map { + switch $0 { + case .singleContract(let id): + return .singleContract(id: id) + case .singleContractDocumentType(let id, let documentTypeName): + return .singleContractDocumentType( + id: id, + documentTypeName: documentTypeName + ) + } + } + ) + }, + disablePublicKeyIds: parsed.disablePublicKeyIds + ) + } +} + +final class PlatformDashConnectDataSource: DashConnectDataSource { + struct DocumentSigningKeyCandidate: Equatable { + let keyId: UInt32 + let purpose: KeyPurpose + let securityLevel: SecurityLevel + let keyType: KeyType + let disabledAt: Int64? + } + + private struct Context { + let wallet: ManagedPlatformWallet + let sdk: SDK + let modelContainer: ModelContainer + let network: Network + let identityId: Data + let identityIndex: UInt32 + let storedUsername: String? + } + + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "dashconnect.platform-data-source") + + static let loginKeyExchangeContractId: Data = { + guard let data = Data.identifier(fromBase58: "7UaqHGBJBbRLJ4fUWS45cnud8PPUugJWoGTt1SKwHJ2P"), + data.count == 32 else { + fatalError("DashConnect loginKeyResponse contract id must be a 32-byte identifier.") + } + return data + }() + static let loginKeyExchangeDocumentType = "loginKeyResponse" + + private let supportedNetwork: DashConnectNetwork + private let store: any DashConnectStore + private let subject: CurrentValueSubject<[DAppConnection], Never> + private let authorizer: DWIdentityAuthorizer + private let keyRegistrationParser: any DashConnectKeyRegistrationParsing + private let now: () -> Date + + init( + supportedNetwork: DashConnectNetwork = .testnet, + store: (any DashConnectStore)? = nil, + authorizer: DWIdentityAuthorizer = DWIdentityAuthorizer(), + keyRegistrationParser: any DashConnectKeyRegistrationParsing = PlatformWalletDashConnectKeyRegistrationParser(), + now: @escaping () -> Date = Date.init + ) { + assert(supportedNetwork == .testnet, "DashConnect Platform publish is currently testnet-only.") + self.supportedNetwork = supportedNetwork + self.store = store ?? UserDefaultsDashConnectStore(network: supportedNetwork) + self.authorizer = authorizer + self.keyRegistrationParser = keyRegistrationParser + self.now = now + self.subject = CurrentValueSubject(self.store.load()) + } + + var connections: AnyPublisher<[DAppConnection], Never> { + subject.eraseToAnyPublisher() + } + + func parseQR(_ content: String) async throws -> DashConnectQr { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + + if DashConnectUri.isKeyUri(trimmed) { + let request = try DashConnectUri.parseKeyRequest(trimmed) + try validateNetwork(request.network) + return .login(request) + } + + if DashConnectUri.isStUri(trimmed) { + let request = try DashConnectUri.parseStRequest(trimmed) + try validateNetwork(request.network) + return .keyRegistration(request) + } + + throw DashConnectMockError.notDashConnectQrCode + } + + func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest { + let fallback = ConnectionRequest(loginRequest: loginRequest) + let existingConnection = existingConnection(for: fallback.appContractId) + + guard let context = try? await requireContext() else { + return ConnectionRequest( + loginRequest: loginRequest, + appLabel: fallback.appLabel, + appUrl: fallback.appUrl, + walletUsername: nil, + walletIdentityId: nil, + existingConnection: existingConnection + ) + } + + let metadata = await resolveAppMetadata(for: loginRequest, sdk: context.sdk) + let walletUsername = await resolveWalletUsername(context: context) + + return ConnectionRequest( + loginRequest: loginRequest, + appLabel: metadata.name.isEmpty ? fallback.appLabel : metadata.name, + appUrl: metadata.url, + walletUsername: walletUsername, + walletIdentityId: context.identityId.toBase58String(), + existingConnection: existingConnection + ) + } + + func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection { + Self.logger.info("🔗 DASHCONNECT :: approveLogin started") + try validateNetwork(request.network) + + let context: Context + do { + context = try await requireContext() + } catch { + Self.logger.error("🔗 DASHCONNECT :: no context — \(error.localizedDescription, privacy: .public)") + throw error + } + do { + try await authorize() + } catch { + Self.logger.error("🔗 DASHCONNECT :: authorization failed — \(error.localizedDescription, privacy: .public)") + throw error + } + + // `deriveIdentityAuthKeyAtSlot` is main-actor isolated in the SDK. + var chainKey = try await MainActor.run { + try context.wallet.deriveIdentityAuthKeyAtSlot( + identityIndex: context.identityIndex, + keyId: UInt32(LoginKeyDerivation.defaultKeyIndex), + network: context.network + ).privateKeyData + } + defer { Self.zero(&chainKey) } + + var loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: context.identityId, + appContractId: request.contractId + ) + defer { Self.zero(&loginKey) } + + var walletEphemeralPrivateKey = try Self.generateEphemeralPrivateKey() + let draft = try Self.buildLoginKeyResponseDraft( + loginKey: loginKey, + appContractId: request.contractId, + appEphemeralPubKey: request.appEphemeralPubKey, + walletEphemeralPrivateKey: &walletEphemeralPrivateKey + ) + + let signer = KeychainSigner(modelContainer: context.modelContainer) + let propertiesJSON = try Self.makeJSONObjectString(from: draft.properties) + + do { + _ = try await context.wallet.createDocument( + ownerIdentityId: context.identityId, + contractId: Self.loginKeyExchangeContractId, + documentType: Self.loginKeyExchangeDocumentType, + propertiesJSON: propertiesJSON, + signer: signer + ) + } catch { + let errorText = String(describing: error).lowercased() + let shouldReplace = errorText.contains("duplicate unique") + || errorText.contains("already exists") + || errorText.contains("duplicate") + guard shouldReplace else { + throw error + } + + let existingDocumentId = try await findExistingLoginKeyResponseDocumentId( + ownerIdentityId: context.identityId, + appContractId: request.contractId, + sdk: context.sdk + ) + let signingKeyId = try selectDocumentSigningKeyId( + wallet: context.wallet, + identityId: context.identityId + ) + + _ = try await context.wallet.replaceDocument( + ownerIdentityId: context.identityId, + contractId: Self.loginKeyExchangeContractId, + documentType: Self.loginKeyExchangeDocumentType, + documentId: existingDocumentId, + propertiesJSON: propertiesJSON, + signingKeyId: signingKeyId, + signer: signer + ) + } + + let preview = await makeConnectionRequest(from: request) + let connection: DAppConnection + do { + var derivedMaterial = try Self.deriveKeyRegistrationMaterial( + chainKeyPrivateBytes: chainKey, + identityId: context.identityId, + appContractId: request.contractId + ) + defer { + Self.zero(&derivedMaterial.loginKey) + Self.zero(&derivedMaterial.authenticationPrivateKey) + Self.zero(&derivedMaterial.encryptionPrivateKey) + } + + let currentPublicKeys = try context.wallet + .managedIdentity(identityId: context.identityId) + .getPublicKeys() + // The `dash-st:` registration happens once per (identity, app): + // after it, the app stops emitting that QR. So the status reflects + // whether the derived login keys are on the identity, not which + // step of the flow just ran. + if Self.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: derivedMaterial.authenticationPublicKeyHash160, + encryptionPublicKey: derivedMaterial.encryptionPublicKey, + currentIdentityPublicKeys: currentPublicKeys + ) { + connection = Self.makeConnection(preview: preview, status: .active, updatedAt: now()) + } else { + connection = Self.makeConnection(preview: preview, status: .approved, updatedAt: now()) + } + } catch { + Self.logger.error( + "🔗 DASHCONNECT :: could not confirm registered login keys after approveLogin — \(error.localizedDescription, privacy: .public)" + ) + // Conservative fallback: asking for a QR is safer than claiming the + // registration is already on the identity when we could not verify it. + connection = Self.makeConnection(preview: preview, status: .approved, updatedAt: now()) + } + + var current = subject.value.filter { $0.id != connection.id } + current.append(connection) + persistAndSend(current) + return connection + } + + func completeKeyRegistration(_ request: DashStRequest) async throws { + try validateNetwork(request.network) + let context = try await requireContext() + let pendingConnection = try pendingApprovedConnectionForKeyRegistration() + guard let pendingAppContractId = Self.decodeIdentifier(pendingConnection.id) else { + throw DashConnectPlatformError.noApprovedConnectionAwaitingKeyRegistration + } + // Chosen approach: (a) deserialize the scanned IdentityUpdateTransition, + // verify it only adds the exact derived login keys for our identity, + // then rebuild the equivalent `updateIdentity(...)` call through the SDK. + let transition = try await MainActor.run { + try keyRegistrationParser.parse(request.transitionBytes) + } + + // `deriveIdentityAuthKeyAtSlot` is main-actor isolated in the SDK. + var chainKey = try await MainActor.run { + try context.wallet.deriveIdentityAuthKeyAtSlot( + identityIndex: context.identityIndex, + keyId: UInt32(LoginKeyDerivation.defaultKeyIndex), + network: context.network + ).privateKeyData + } + defer { Self.zero(&chainKey) } + + var derivedMaterial = try Self.deriveKeyRegistrationMaterial( + chainKeyPrivateBytes: chainKey, + identityId: context.identityId, + appContractId: pendingAppContractId + ) + defer { + Self.zero(&derivedMaterial.loginKey) + Self.zero(&derivedMaterial.authenticationPrivateKey) + Self.zero(&derivedMaterial.encryptionPrivateKey) + } + + let validated = try Self.validateKeyRegistration( + transition, + identityId: context.identityId, + appContractId: pendingAppContractId, + derivedMaterial: derivedMaterial + ) + + let currentPublicKeys = try context.wallet + .managedIdentity(identityId: context.identityId) + .getPublicKeys() + let missingKeys = Self.missingKeyRegistrationKeys( + validated, + currentIdentityPublicKeys: currentPublicKeys + ) + + guard !missingKeys.isEmpty else { + persistAndSend( + subject.value.map { connection in + guard connection.id == pendingConnection.id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .active, + updatedAt: now() + ) + } + ) + return + } + + try await authorize() + + let signer = KeychainSigner(modelContainer: context.modelContainer) + try await signer.withAdditionalSigningKeys([ + ( + publicKey: validated.authenticationKey.publicKeyData, + privateKey: derivedMaterial.authenticationPrivateKey + ), + ( + publicKey: validated.encryptionKey.publicKeyData, + privateKey: derivedMaterial.encryptionPrivateKey + ), + ]) { + try await context.wallet.updateIdentity( + identityId: context.identityId, + addPublicKeys: missingKeys.map(Self.makeManagedIdentityPubkey), + signer: signer + ) + } + + persistAndSend( + subject.value.map { connection in + guard connection.id == pendingConnection.id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .active, + updatedAt: now() + ) + } + ) + } + + func remove(id: String) async { + persistAndSend(subject.value.filter { $0.id != id }) + } + + func disconnect(id: String) async { + let disconnectedAt = now() + // Local-only: keep the published `loginKeyResponse` document intact. + // Re-approving the same `dash-key:` QR is enough to return to `.active` + // once the identity keys are still registered. + persistAndSend(subject.value.map { connection in + guard connection.id == id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .approved, + updatedAt: disconnectedAt + ) + }) + } + + static func buildLoginKeyResponseDraft( + loginKey: Data, + appContractId: Data, + appEphemeralPubKey: Data, + walletEphemeralPrivateKey: inout Data, + encryptLoginKey: (Data, Data, Data) throws -> Data = { loginKey, walletPriv, appPub in + try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletPriv, + appEphemeralPub: appPub + ) + } + ) throws -> DashConnectLoginKeyResponseDraft { + defer { zero(&walletEphemeralPrivateKey) } + + let appEphemeralPubKeyHash = KeyExchangeCrypto.hash160(appEphemeralPubKey) + guard appEphemeralPubKeyHash.count == 20 else { + throw DashConnectPlatformError.invalidHash160 + } + + let walletEphemeralPublicKey = try Secp256k1.compressedPublicKey(privateKey: walletEphemeralPrivateKey) + let encryptedPayload = try encryptLoginKey(loginKey, walletEphemeralPrivateKey, appEphemeralPubKey) + + return DashConnectLoginKeyResponseDraft( + properties: [ + "contractId": appContractId.toBase58String(), + "appEphemeralPubKeyHash": appEphemeralPubKeyHash.toHexString(), + "walletEphemeralPubKey": walletEphemeralPublicKey.toHexString(), + "encryptedPayload": encryptedPayload.toHexString(), + "keyIndex": LoginKeyDerivation.defaultKeyIndex, + ], + walletEphemeralPublicKey: walletEphemeralPublicKey, + encryptedPayload: encryptedPayload + ) + } + + static func validateKeyRegistration( + _ transition: DashConnectKeyRegistrationTransition, + chainKeyPrivateBytes: Data, + identityId: Data, + appContractId: Data + ) throws -> DashConnectKeyRegistrationValidationResult { + var derivedMaterial = try deriveKeyRegistrationMaterial( + chainKeyPrivateBytes: chainKeyPrivateBytes, + identityId: identityId, + appContractId: appContractId + ) + defer { + zero(&derivedMaterial.loginKey) + zero(&derivedMaterial.authenticationPrivateKey) + zero(&derivedMaterial.encryptionPrivateKey) + } + + return try validateKeyRegistration( + transition, + identityId: identityId, + appContractId: appContractId, + derivedMaterial: derivedMaterial + ) + } + + private static func deriveKeyRegistrationMaterial( + chainKeyPrivateBytes: Data, + identityId: Data, + appContractId: Data + ) throws -> DashConnectKeyRegistrationDerivedMaterial { + var loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKeyPrivateBytes, + identityId: identityId, + appContractId: appContractId + ) + var authenticationPrivateKey = Data() + var encryptionPrivateKey = Data() + + do { + authenticationPrivateKey = try KeyExchangeCrypto.deriveAuthPrivateKey( + loginKey: loginKey, + identityId: identityId + ) + let authenticationPublicKey = try Secp256k1.compressedPublicKey( + privateKey: authenticationPrivateKey + ) + let authenticationPublicKeyHash160 = KeyExchangeCrypto.hash160(authenticationPublicKey) + + encryptionPrivateKey = try KeyExchangeCrypto.deriveEncryptionPrivateKey( + loginKey: loginKey, + identityId: identityId + ) + let encryptionPublicKey = try Secp256k1.compressedPublicKey( + privateKey: encryptionPrivateKey + ) + + return DashConnectKeyRegistrationDerivedMaterial( + loginKey: loginKey, + authenticationPrivateKey: authenticationPrivateKey, + authenticationPublicKeyHash160: authenticationPublicKeyHash160, + encryptionPrivateKey: encryptionPrivateKey, + encryptionPublicKey: encryptionPublicKey + ) + } catch { + zero(&loginKey) + zero(&authenticationPrivateKey) + zero(&encryptionPrivateKey) + throw error + } + } + + private static func validateKeyRegistration( + _ transition: DashConnectKeyRegistrationTransition, + identityId: Data, + appContractId: Data, + derivedMaterial: DashConnectKeyRegistrationDerivedMaterial + ) throws -> DashConnectKeyRegistrationValidationResult { + guard transition.identityId == identityId else { + throw DashConnectPlatformError.keyRegistrationWrongIdentity + } + guard transition.disablePublicKeyIds.isEmpty else { + throw DashConnectPlatformError.keyRegistrationUnexpectedMutation + } + guard transition.addPublicKeys.count == 2 else { + throw DashConnectPlatformError.keyRegistrationUnexpectedMutation + } + + var authenticationKey: DashConnectKeyRegistrationKey? + var encryptionKey: DashConnectKeyRegistrationKey? + + for key in transition.addPublicKeys { + switch key.purpose { + case .authentication: + guard authenticationKey == nil, + key.keyType == .ecdsaHash160, + key.securityLevel == .high, + key.publicKeyData == derivedMaterial.authenticationPublicKeyHash160 else { + throw DashConnectPlatformError.keyRegistrationMismatchedDerivedKey(.authentication) + } + authenticationKey = key + case .encryption: + guard encryptionKey == nil, + key.keyType == .ecdsaSecp256k1, + key.securityLevel == .medium, + key.publicKeyData == derivedMaterial.encryptionPublicKey else { + throw DashConnectPlatformError.keyRegistrationMismatchedDerivedKey(.encryption) + } + encryptionKey = key + default: + throw DashConnectPlatformError.keyRegistrationUnexpectedMutation + } + } + + guard let authenticationKey, let encryptionKey else { + throw DashConnectPlatformError.keyRegistrationUnexpectedMutation + } + + return DashConnectKeyRegistrationValidationResult( + appContractId: appContractId, + authenticationKey: authenticationKey, + encryptionKey: encryptionKey + ) + } + + static func hasRegisteredLoginKeys( + authenticationPublicKeyHash160: Data, + encryptionPublicKey: Data, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> Bool { + hasRegisteredAuthenticationKey( + authenticationPublicKeyHash160: authenticationPublicKeyHash160, + currentIdentityPublicKeys: currentIdentityPublicKeys + ) && hasRegisteredEncryptionKey( + encryptionPublicKey: encryptionPublicKey, + currentIdentityPublicKeys: currentIdentityPublicKeys + ) + } + + static func missingKeyRegistrationKeys( + _ validated: DashConnectKeyRegistrationValidationResult, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> [DashConnectKeyRegistrationKey] { + let hasAuthenticationKey = hasRegisteredAuthenticationKey( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + currentIdentityPublicKeys: currentIdentityPublicKeys + ) + let hasEncryptionKey = hasRegisteredEncryptionKey( + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentIdentityPublicKeys + ) + + var missingKeys: [DashConnectKeyRegistrationKey] = [] + if !hasAuthenticationKey { + missingKeys.append(validated.authenticationKey) + } + if !hasEncryptionKey { + missingKeys.append(validated.encryptionKey) + } + return missingKeys + } + + private static func makeConnection( + preview: ConnectionRequest, + status: ConnectionStatus, + updatedAt: Date + ) -> DAppConnection { + DAppConnection( + id: preview.appContractId, + name: preview.appLabel.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : preview.appLabel, + url: preview.appUrl, + status: status, + updatedAt: updatedAt + ) + } + + private static func hasRegisteredAuthenticationKey( + authenticationPublicKeyHash160: Data, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> Bool { + currentIdentityPublicKeys.contains { + $0.purpose == .authentication + && $0.securityLevel == .high + && $0.keyType == .ecdsaHash160 + && $0.disabledAt == nil + && $0.data == authenticationPublicKeyHash160 + } + } + + private static func hasRegisteredEncryptionKey( + encryptionPublicKey: Data, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> Bool { + currentIdentityPublicKeys.contains { + $0.purpose == .encryption + && $0.securityLevel == .medium + && $0.keyType == .ecdsaSecp256k1 + && $0.disabledAt == nil + && $0.data == encryptionPublicKey + } + } + + private func persistAndSend(_ connections: [DAppConnection]) { + let sorted = connections.sorted(by: { $0.updatedAt > $1.updatedAt }) + store.save(sorted) + subject.send(sorted) + } + + private func existingConnection(for contractId: String) -> DAppConnection? { + subject.value.first { $0.id == contractId } + } + + private func pendingApprovedConnectionForKeyRegistration() throws -> DAppConnection { + try Self.pendingApprovedConnectionForKeyRegistration(in: subject.value) + } + + static func pendingApprovedConnectionForKeyRegistration( + in connections: [DAppConnection] + ) throws -> DAppConnection { + guard let connection = connections + .filter({ $0.status == .approved }) + .max(by: { $0.updatedAt < $1.updatedAt }) else { + throw DashConnectPlatformError.noApprovedConnectionAwaitingKeyRegistration + } + return connection + } + + private func validateNetwork(_ network: DashConnectNetwork) throws { + guard network == supportedNetwork else { + throw DashConnectPlatformError.unsupportedRequestNetwork( + expected: supportedNetwork, + actual: network + ) + } + } + + private func authorize() async throws { + do { + try await authorizer.authorize() + } catch DWIdentityAuthorizer.AuthError.cancelled { + throw DashConnectPlatformError.authorizationCancelled + } catch { + throw DashConnectPlatformError.authorizationFailed + } + } + + private func requireContext() async throws -> Context { + let runtime = await MainActor.run { + ( + wallet: SwiftDashSDKHost.shared.wallet, + sdk: SwiftDashSDKHost.shared.sdk, + modelContainer: SwiftDashSDKHost.shared.modelContainer, + runningNetwork: SwiftDashSDKHost.shared.runningNetwork, + identityId: DWCurrentUserIdentityInfo.shared.identityId, + storedUsername: DWCurrentUserIdentityInfo.shared.username + ) + } + + guard let wallet = runtime.wallet else { + Self.logger.error("🔗 DASHCONNECT :: context missing: wallet") + throw DashConnectPlatformError.noWallet + } + guard let sdk = runtime.sdk else { + Self.logger.error("🔗 DASHCONNECT :: context missing: sdk") + throw DashConnectPlatformError.noSDK + } + guard let modelContainer = runtime.modelContainer else { + Self.logger.error("🔗 DASHCONNECT :: context missing: modelContainer") + throw DashConnectPlatformError.noModelContainer + } + guard let network = runtime.runningNetwork else { + Self.logger.error("🔗 DASHCONNECT :: context missing: runningNetwork") + throw DashConnectPlatformError.noWallet + } + guard network == .testnet else { + Self.logger.error("🔗 DASHCONNECT :: wallet is on \(String(describing: network), privacy: .public), DashConnect is testnet-only") + throw DashConnectPlatformError.unsupportedRuntimeNetwork(network) + } + guard let identityId = runtime.identityId else { + Self.logger.error("🔗 DASHCONNECT :: context missing: identityId — no registered DashPay identity on this wallet") + throw DashConnectPlatformError.noIdentity + } + + let identityIndex = try wallet.managedIdentity(identityId: identityId).getIdentityIndex() ?? 0 + + return Context( + wallet: wallet, + sdk: sdk, + modelContainer: modelContainer, + network: network, + identityId: identityId, + identityIndex: identityIndex, + storedUsername: runtime.storedUsername + ) + } + + private func resolveAppMetadata(for request: DashKeyRequest, sdk: SDK) async -> DashConnectAppMetadata { + let contractId = request.contractId.toBase58String() + let fallback = DashConnectFallbackAppMetadata.resolve( + contractId: contractId, + unauthenticatedLabel: request.label + ) + + do { + let contract = try await sdk.dataContractGet(id: contractId) + let ownerId = contract["ownerId"] as? String ?? contract["$ownerId"] as? String + + if let ownerId, + let usernames = try? await sdk.dpnsGetUsername(identityId: ownerId, limit: 1), + let label = usernames.first?["label"] as? String, + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return DashConnectAppMetadata(name: label, url: fallback.url) + } + } catch { + Self.logger.error("🔗 DASHCONNECT :: app metadata resolution failed for \(contractId, privacy: .public): \(String(describing: error), privacy: .public)") + } + + return fallback + } + + private func resolveWalletUsername(context: Context) async -> String? { + let identityId = context.identityId.toBase58String() + + if let usernames = try? await context.sdk.dpnsGetUsername(identityId: identityId, limit: 1), + let label = usernames.first?["label"] as? String, + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return label + } + + return context.storedUsername + } + + private func findExistingLoginKeyResponseDocumentId( + ownerIdentityId: Data, + appContractId: Data, + sdk: SDK + ) async throws -> Data { + let whereClause = """ + [["$ownerId","==","\(ownerIdentityId.toBase58String())"],["contractId","==","\(appContractId.toBase58String())"]] + """ + + let response = try await sdk.documentList( + dataContractId: Self.loginKeyExchangeContractId.toBase58String(), + documentType: Self.loginKeyExchangeDocumentType, + whereClause: whereClause, + limit: 1 + ) + + guard let documents = response["documents"] as? [[String: Any]], + let id = documents.first?["$id"] as? String, + let identifier = Self.decodeIdentifier(id), + identifier.count == 32 else { + throw DashConnectPlatformError.existingDocumentLookupFailed + } + + return identifier + } + + private func selectDocumentSigningKeyId( + wallet: ManagedPlatformWallet, + identityId: Data + ) throws -> UInt32 { + let keys = try wallet.managedIdentity(identityId: identityId) + .getPublicKeys() + .map { + DocumentSigningKeyCandidate( + keyId: UInt32(bitPattern: $0.keyId), + purpose: $0.purpose, + securityLevel: $0.securityLevel, + keyType: $0.keyType, + disabledAt: $0.disabledAt + ) + } + + return try Self.selectDocumentSigningKeyId(from: keys) + } + + static func selectDocumentSigningKeyId( + from keys: [DocumentSigningKeyCandidate] + ) throws -> UInt32 { + let eligible = keys + .filter { + $0.disabledAt == nil + && $0.purpose == .authentication + && $0.keyType == .ecdsaSecp256k1 + && ($0.securityLevel == .high || $0.securityLevel == .critical) + } + .sorted { + let lhsPriority = signingPriority(for: $0.securityLevel) + let rhsPriority = signingPriority(for: $1.securityLevel) + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return $0.keyId < $1.keyId + } + + guard let key = eligible.first else { + throw DashConnectPlatformError.noAuthenticationKey + } + + return key.keyId + } + + private static func signingPriority(for securityLevel: SecurityLevel) -> Int { + switch securityLevel { + case .high: + return 0 + case .critical: + return 1 + case .master, .medium: + return 2 + } + } + + private static func makeManagedIdentityPubkey( + from key: DashConnectKeyRegistrationKey + ) -> ManagedPlatformWallet.IdentityPubkey { + ManagedPlatformWallet.IdentityPubkey( + keyId: key.keyId, + keyType: key.keyType, + purpose: key.purpose, + securityLevel: key.securityLevel, + pubkeyBytes: key.publicKeyData, + contractBounds: key.contractBounds.map { + switch $0 { + case .singleContract(let id): + return .singleContract(id: id) + case .singleContractDocumentType(let id, let documentTypeName): + return .singleContractDocumentType(id: id, documentTypeName: documentTypeName) + } + } + ) + } + + private static func generateEphemeralPrivateKey() throws -> Data { + while true { + var candidate = try randomBytes(count: 32) + if (try? Secp256k1.compressedPublicKey(privateKey: candidate)) != nil { + return candidate + } + zero(&candidate) + } + } + + private static func randomBytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + zero(&bytes) + throw DashConnectPlatformError.randomGenerationFailed(status) + } + let data = Data(bytes) + zero(&bytes) + return data + } + + private static func decodeIdentifier(_ string: String) -> Data? { + if let base58 = Data.identifier(fromBase58: string), base58.count == 32 { + return base58 + } + if let base64 = Data(base64Encoded: string), base64.count == 32 { + return base64 + } + return nil + } + + private static func makeJSONObjectString(from properties: [String: AnyHashable]) throws -> String { + let dictionary = Dictionary(uniqueKeysWithValues: properties.map { ($0.key, $0.value.base) }) + let data = try JSONSerialization.data(withJSONObject: dictionary, options: [.sortedKeys]) + guard let string = String(data: data, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + return string + } + + static func zero(_ data: inout Data) { + data.withUnsafeMutableBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return + } + baseAddress.initializeMemory(as: UInt8.self, repeating: 0, count: buffer.count) + } + } + + private static func zero(_ bytes: inout [UInt8]) { + bytes.withUnsafeMutableBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return + } + baseAddress.initializeMemory(as: UInt8.self, repeating: 0, count: buffer.count) + } + } +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectRequests.swift b/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectRequests.swift new file mode 100644 index 000000000..5c3f6583e --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectRequests.swift @@ -0,0 +1,21 @@ +import Foundation + +enum DashConnectNetwork: String { + case mainnet = "m" + case testnet = "t" + case devnet = "d" +} + +struct DashKeyRequest: Equatable { + let appEphemeralPubKey: Data + let contractId: Data + /// Unauthenticated display label from the QR payload. This is spoofable and must + /// not be treated as a verified app identity. + let label: String + let network: DashConnectNetwork +} + +struct DashStRequest: Equatable { + let transitionBytes: Data + let network: DashConnectNetwork +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectUri.swift b/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectUri.swift new file mode 100644 index 000000000..7c781cd6a --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectUri.swift @@ -0,0 +1,236 @@ +import Foundation + +enum DashConnectUriError: LocalizedError, Equatable { + case invalidKeyScheme + case invalidStScheme + case unexpectedAuthority + case missingQuery + case emptyBody + case missingNetwork + case missingVersion + case unsupportedVersion + case unknownNetwork + case invalidBase58 + case keyPayloadTooShort + case invalidKeyPayloadVersion + case keyLabelTooLong + case keyLabelLengthOverrunsPayload + case invalidEphemeralPublicKey + case emptyTransitionPayload + + var errorDescription: String? { + switch self { + case .invalidKeyScheme: + return "The URI does not use the dash-key scheme." + case .invalidStScheme: + return "The URI does not use the dash-st scheme." + case .unexpectedAuthority: + return "DashConnect URIs must not contain // authority separators." + case .missingQuery: + return "DashConnect URIs must include query parameters." + case .emptyBody: + return "DashConnect URIs must include a non-empty Base58 body." + case .missingNetwork: + return "DashConnect URIs must include the network query item." + case .missingVersion: + return "DashConnect URIs must include the version query item." + case .unsupportedVersion: + return "Only DashConnect URI version 1 is supported." + case .unknownNetwork: + return "The URI contains an unknown DashConnect network code." + case .invalidBase58: + return "The URI body is not valid Base58." + case .keyPayloadTooShort: + return "The dash-key payload is shorter than the minimum 67-byte format." + case .invalidKeyPayloadVersion: + return "The dash-key payload version byte is not supported." + case .keyLabelTooLong: + return "The dash-key payload label is longer than 64 bytes." + case .keyLabelLengthOverrunsPayload: + return "The dash-key payload label length overruns the payload." + case .invalidEphemeralPublicKey: + return "The dash-key payload contains an invalid compressed secp256k1 point." + case .emptyTransitionPayload: + return "The dash-st payload must be non-empty." + } + } +} + +enum DashConnectUri { + private static let keyScheme = "dash-key:" + private static let stScheme = "dash-st:" + private static let expectedVersion = "1" + private static let keyPayloadVersion: UInt8 = 0x01 + private static let compressedPubKeyLength = 33 + private static let contractIdLength = 32 + private static let minKeyPayloadLength = 1 + compressedPubKeyLength + contractIdLength + 1 + private static let maxLabelLength = 64 + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + private static let base58Reverse: [Int8] = { + var map = [Int8](repeating: -1, count: 128) + for (index, byte) in base58Alphabet.enumerated() { + map[Int(byte)] = Int8(index) + } + return map + }() + + static func isKeyUri(_ uri: String) -> Bool { + uri.hasPrefix(keyScheme) + } + + static func isStUri(_ uri: String) -> Bool { + uri.hasPrefix(stScheme) + } + + static func parseKeyRequest(_ uri: String) throws -> DashKeyRequest { + let envelope = try parseEnvelope(uri, scheme: keyScheme, invalidScheme: .invalidKeyScheme) + let payload = envelope.payload + + guard payload.count >= minKeyPayloadLength else { + throw DashConnectUriError.keyPayloadTooShort + } + guard payload[payload.startIndex] == keyPayloadVersion else { + throw DashConnectUriError.invalidKeyPayloadVersion + } + + let pubKeyStart = payload.index(after: payload.startIndex) + let pubKeyEnd = payload.index(pubKeyStart, offsetBy: compressedPubKeyLength) + let contractEnd = payload.index(pubKeyEnd, offsetBy: contractIdLength) + let labelLengthIndex = contractEnd + let labelStart = payload.index(after: labelLengthIndex) + + let appEphemeralPubKey = Data(payload[pubKeyStart ..< pubKeyEnd]) + guard Secp256k1.isValidCompressedPoint(appEphemeralPubKey) else { + throw DashConnectUriError.invalidEphemeralPublicKey + } + + let contractId = Data(payload[pubKeyEnd ..< contractEnd]) + let labelLength = Int(payload[labelLengthIndex]) + guard labelLength <= maxLabelLength else { + throw DashConnectUriError.keyLabelTooLong + } + + let requiredLabelEndOffset = payload.distance(from: payload.startIndex, to: labelStart) + let expectedCount = requiredLabelEndOffset + labelLength + guard payload.count >= expectedCount else { + throw DashConnectUriError.keyLabelLengthOverrunsPayload + } + + let labelEnd = payload.index(labelStart, offsetBy: labelLength) + let label = String(decoding: payload[labelStart ..< labelEnd], as: UTF8.self) + + return DashKeyRequest( + appEphemeralPubKey: appEphemeralPubKey, + contractId: contractId, + label: label, + network: envelope.network + ) + } + + static func parseStRequest(_ uri: String) throws -> DashStRequest { + let envelope = try parseEnvelope(uri, scheme: stScheme, invalidScheme: .invalidStScheme) + guard !envelope.payload.isEmpty else { + throw DashConnectUriError.emptyTransitionPayload + } + + return DashStRequest(transitionBytes: envelope.payload, network: envelope.network) + } + + private static func parseEnvelope(_ uri: String, + scheme: String, + invalidScheme: DashConnectUriError) throws + -> (payload: Data, network: DashConnectNetwork) { + guard uri.hasPrefix(scheme) else { + throw invalidScheme + } + + let remainder = String(uri.dropFirst(scheme.count)) + guard !remainder.hasPrefix("//") else { + throw DashConnectUriError.unexpectedAuthority + } + + guard let queryIndex = remainder.firstIndex(of: "?") else { + throw DashConnectUriError.missingQuery + } + + let body = String(remainder[.. [String: String] { + var result: [String: String] = [:] + + for item in query.split(separator: "&", omittingEmptySubsequences: false) { + let parts = item.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + let key = String(parts[0]) + let value = parts.count == 2 ? String(parts[1]) : "" + if !key.isEmpty, result[key] == nil { + result[key] = value + } + } + + return result + } + + private static func base58Decode(_ string: String) -> Data? { + let input = Array(string.utf8) + var leadingZeros = 0 + while leadingZeros < input.count, input[leadingZeros] == base58Alphabet[0] { + leadingZeros += 1 + } + + let size = input.count * 733 / 1000 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< input.count { + let byte = input[i] + guard byte < 128 else { return nil } + + let digit = base58Reverse[Int(byte)] + guard digit >= 0 else { return nil } + + var carry = Int(digit) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 58 * Int(buffer[j]) + buffer[j] = UInt8(carry & 0xff) + carry >>= 8 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = [UInt8](repeating: 0, count: leadingZeros) + if start < buffer.count { + result.append(contentsOf: buffer[start...]) + } + return Data(result) + } +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/KeyExchangeCrypto.swift b/DashWallet/Sources/Models/DashConnect/Protocol/KeyExchangeCrypto.swift new file mode 100644 index 000000000..8f1d82b62 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/KeyExchangeCrypto.swift @@ -0,0 +1,221 @@ +import CryptoKit +import Foundation +import Security +import SwiftDashSDK + +enum KeyExchangeCrypto { + private enum CryptoError: LocalizedError { + case invalidSharedXLength + case invalidLoginKeyLength + case invalidIdentityIdLength + case invalidNonceLength + case invalidPayloadLength + case randomNonceGenerationFailed(OSStatus) + case invalidEncryptedPayload + case hash160Failed + + var errorDescription: String? { + switch self { + case .invalidSharedXLength: + "The ECDH shared X coordinate must be exactly 32 bytes." + case .invalidLoginKeyLength: + "DashConnect login keys must be exactly 32 bytes." + case .invalidIdentityIdLength: + "DashConnect identity IDs must be exactly 32 bytes." + case .invalidNonceLength: + "DashConnect AES-GCM nonces must be exactly 12 bytes." + case .invalidPayloadLength: + "DashConnect encrypted login-key payloads must be exactly 60 bytes." + case .randomNonceGenerationFailed(let status): + "Failed to generate a random AES-GCM nonce (\(status))." + case .invalidEncryptedPayload: + "Failed to assemble the AES-GCM encrypted login-key payload." + case .hash160Failed: + "Failed to compute hash160 via platform_wallet_hash160." + } + } + } + + private static let hash160Length = 20 + private static let aesKeyLength = 32 + private static let loginKeyLength = 32 + private static let nonceLength = 12 + private static let encryptedPayloadLength = 60 + private static let keyExchangeSalt = Data("dash:key-exchange:v1".utf8) + private static let authInfo = Data("auth".utf8) + private static let encryptionInfo = Data("encryption".utf8) + + /// RIPEMD160(SHA256(data)) using the shared Rust FFI helper. + /// + /// This deliberately duplicates the direct `platform_wallet_hash160` call pattern from + /// `KeychainManager.computePublicKeyHashHex` so we reuse the same primitive without + /// adding a Swift-side RIPEMD-160 implementation. Empty input or FFI failure returns + /// an empty Data sentinel, mirroring the existing KeychainManager helper. + static func hash160(_ data: Data) -> Data { + guard !data.isEmpty else { + return Data() + } + + var out = [UInt8](repeating: 0, count: hash160Length) + let rc: Int32 = data.withUnsafeBytes { raw -> Int32 in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { + return -1 + } + + return out.withUnsafeMutableBufferPointer { buffer -> Int32 in + guard let outBase = buffer.baseAddress else { + return -1 + } + + return platform_wallet_hash160(base, UInt(data.count), outBase) + } + } + + guard rc == 0 else { + zero(&out) + return Data() + } + + let result = Data(out) + zero(&out) + return result + } + + /// The returned Data is owned by the caller to clear. + static func deriveAesKey(sharedX: Data) throws -> Data { + try requireLength(sharedX, expected: aesKeyLength, error: CryptoError.invalidSharedXLength) + return try deriveHKDFKey( + inputKeyMaterial: sharedX, + salt: keyExchangeSalt, + info: Data(), + outputByteCount: aesKeyLength + ) + } + + /// The returned Data is owned by the caller to clear. + static func encryptLoginKey(_ loginKey: Data, + walletEphemeralPriv: Data, + appEphemeralPub: Data) throws -> Data { + var nonceBytes = [UInt8](repeating: 0, count: nonceLength) + let status = SecRandomCopyBytes(kSecRandomDefault, nonceBytes.count, &nonceBytes) + guard status == errSecSuccess else { + zero(&nonceBytes) + throw CryptoError.randomNonceGenerationFailed(status) + } + + let nonce = Data(nonceBytes) + zero(&nonceBytes) + return try encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: nonce + ) + } + + /// Fixed-nonce variant. Tests only — never call from the login flow. + /// + /// The returned Data is owned by the caller to clear. + static func encryptLoginKey(_ loginKey: Data, + walletEphemeralPriv: Data, + appEphemeralPub: Data, + nonce: Data) throws -> Data { + try requireLength(loginKey, expected: loginKeyLength, error: CryptoError.invalidLoginKeyLength) + try requireLength(nonce, expected: nonceLength, error: CryptoError.invalidNonceLength) + + let sharedX = try Secp256k1.ecdhSharedX(privateKey: walletEphemeralPriv, publicKey: appEphemeralPub) + let aesKey = try deriveAesKey(sharedX: sharedX) + let symmetricKey = SymmetricKey(data: aesKey) + let sealedBox = try AES.GCM.seal( + loginKey, + using: symmetricKey, + nonce: try AES.GCM.Nonce(data: nonce) + ) + + guard let combined = sealedBox.combined else { + throw CryptoError.invalidEncryptedPayload + } + assert(combined.count == encryptedPayloadLength) + guard combined.count == encryptedPayloadLength else { + throw CryptoError.invalidPayloadLength + } + + return combined + } + + /// Tests only — this exists to verify the key-exchange round trip and is not used by + /// the login flow. The returned Data is owned by the caller to clear. + static func decryptLoginKey(_ payload: Data, + walletEphemeralPriv: Data, + appEphemeralPub: Data) throws -> Data { + try requireLength(payload, expected: encryptedPayloadLength, error: CryptoError.invalidPayloadLength) + + let sharedX = try Secp256k1.ecdhSharedX(privateKey: walletEphemeralPriv, publicKey: appEphemeralPub) + let aesKey = try deriveAesKey(sharedX: sharedX) + let symmetricKey = SymmetricKey(data: aesKey) + let sealedBox = try AES.GCM.SealedBox(combined: payload) + let loginKey = try AES.GCM.open(sealedBox, using: symmetricKey) + + try requireLength(loginKey, expected: loginKeyLength, error: CryptoError.invalidLoginKeyLength) + return loginKey + } + + /// The returned Data is owned by the caller to clear. + static func deriveAuthPrivateKey(loginKey: Data, identityId: Data) throws -> Data { + try requireLength(loginKey, expected: loginKeyLength, error: CryptoError.invalidLoginKeyLength) + try requireLength(identityId, expected: loginKeyLength, error: CryptoError.invalidIdentityIdLength) + + return try deriveHKDFKey( + inputKeyMaterial: loginKey, + salt: identityId, + info: authInfo, + outputByteCount: loginKeyLength + ) + } + + /// The returned Data is owned by the caller to clear. + static func deriveEncryptionPrivateKey(loginKey: Data, identityId: Data) throws -> Data { + try requireLength(loginKey, expected: loginKeyLength, error: CryptoError.invalidLoginKeyLength) + try requireLength(identityId, expected: loginKeyLength, error: CryptoError.invalidIdentityIdLength) + + return try deriveHKDFKey( + inputKeyMaterial: loginKey, + salt: identityId, + info: encryptionInfo, + outputByteCount: loginKeyLength + ) + } + + private static func deriveHKDFKey(inputKeyMaterial: Data, + salt: Data, + info: Data, + outputByteCount: Int) throws -> Data { + let key = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: inputKeyMaterial), + salt: salt, + info: info, + outputByteCount: outputByteCount + ) + + return key.withUnsafeBytes { bytes in + Data(bytes) + } + } + + private static func requireLength(_ data: Data, + expected: Int, + error: Error) throws { + guard data.count == expected else { + throw error + } + } + + private static func zero(_ bytes: inout [UInt8]) { + bytes.withUnsafeMutableBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return + } + baseAddress.initializeMemory(as: UInt8.self, repeating: 0, count: buffer.count) + } + } +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/LoginKeyDerivation.swift b/DashWallet/Sources/Models/DashConnect/Protocol/LoginKeyDerivation.swift new file mode 100644 index 000000000..8c5eca8c8 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/LoginKeyDerivation.swift @@ -0,0 +1,68 @@ +import CryptoKit +import Foundation + +enum LoginKeyDerivation { + static let defaultKeyIndex = 0 + + private enum DerivationError: LocalizedError { + case invalidChainKeyLength + case invalidIdentityIdLength + case invalidAppContractIdLength + + var errorDescription: String? { + switch self { + case .invalidChainKeyLength: + "The identity auth-chain private key must be exactly 32 bytes." + case .invalidIdentityIdLength: + "DashConnect identity IDs must be exactly 32 bytes." + case .invalidAppContractIdLength: + "DashConnect app contract IDs must be exactly 32 bytes." + } + } + } + + private static let loginKeyInfoPrefix = Data("dash:login-key:v1".utf8) + private static let keyLength = 32 + + /// The returned Data is owned by the caller to clear. + static func deriveLoginKey(chainKeyPrivateBytes: Data, + identityId: Data, + appContractId: Data) throws -> Data { + guard chainKeyPrivateBytes.count == keyLength else { + throw DerivationError.invalidChainKeyLength + } + guard identityId.count == keyLength else { + throw DerivationError.invalidIdentityIdLength + } + guard appContractId.count == keyLength else { + throw DerivationError.invalidAppContractIdLength + } + + var info = loginKeyInfoPrefix + info.append(appContractId) + // The HKDF info is protocol metadata (`dash:login-key:v1` + contract ID), not a + // secret. The returned login key remains caller-owned to clear. + return try deriveHKDFKey( + inputKeyMaterial: chainKeyPrivateBytes, + salt: identityId, + info: info, + outputByteCount: keyLength + ) + } + + private static func deriveHKDFKey(inputKeyMaterial: Data, + salt: Data, + info: Data, + outputByteCount: Int) throws -> Data { + let key = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: inputKeyMaterial), + salt: salt, + info: info, + outputByteCount: outputByteCount + ) + + return key.withUnsafeBytes { bytes in + Data(bytes) + } + } +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/Secp256k1.swift b/DashWallet/Sources/Models/DashConnect/Protocol/Secp256k1.swift new file mode 100644 index 000000000..e5fb69bb9 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/Secp256k1.swift @@ -0,0 +1,124 @@ +import Foundation +import SwiftDashSDK + +/// The secp256k1 operations DashConnect's key exchange needs. Deliberately minimal — +/// this is not a general-purpose curve wrapper. +enum Secp256k1 { + private enum BackendError: Int, LocalizedError { + case invalidPrivateKeyLength = 1 + case invalidPrivateKey + case invalidPublicKeyLength + case invalidPublicKey + case pointAtInfinity + case backendUnavailable + + var errorDescription: String? { + switch self { + case .invalidPrivateKeyLength: + "secp256k1 private keys must be exactly 32 bytes." + case .invalidPrivateKey: + "The provided private key is not a valid secp256k1 scalar." + case .invalidPublicKeyLength: + "Compressed secp256k1 public keys must be exactly 33 bytes." + case .invalidPublicKey: + "The provided compressed point is not a valid secp256k1 public key." + case .pointAtInfinity: + "ECDH produced the point at infinity." + case .backendUnavailable: + "Failed to initialize the secp256k1 backend." + } + } + } + + /// True when `pubKey` is a well-formed 33-byte compressed point that lies on the + /// curve. The QR is untrusted input, so this gates every ECDH call. + static func isValidCompressedPoint(_ pubKey: Data) -> Bool { + do { + let publicKeyBytes = try validatedCompressedPublicKeyBytes(pubKey) + return Secp256k1Primitives.isValidCompressedPoint(Data(publicKeyBytes)) + } catch { + return false + } + } + + /// The 33-byte compressed public key for a 32-byte private key. + static func compressedPublicKey(privateKey: Data) throws -> Data { + var privateKeyBytes = try validatedPrivateKeyBytes(privateKey) + defer { zero(&privateKeyBytes) } + do { + return try Secp256k1Primitives.compressedPublicKey(privateKey: Data(privateKeyBytes)) + } catch let error as PlatformWalletError { + switch error { + case .invalidParameter: + throw BackendError.invalidPrivateKey + default: + throw BackendError.backendUnavailable + } + } catch { + throw BackendError.backendUnavailable + } + } + + /// The 32-byte big-endian X coordinate of `privateKey * publicKey`, left-padded. + /// + /// This is the ECDH shared secret the protocol feeds to HKDF. It is the raw affine X + /// of the product point — NOT hashed, and NOT the default hashed ECDH output that + /// applies SHA256 to the compressed point. Using the hashed variant would silently + /// produce a shared secret the DApp cannot reproduce. + static func ecdhSharedX(privateKey: Data, publicKey: Data) throws -> Data { + var privateKeyBytes = try validatedPrivateKeyBytes(privateKey) + defer { zero(&privateKeyBytes) } + + let publicKeyBytes = try validatedCompressedPublicKeyBytes(publicKey) + guard Secp256k1Primitives.isValidCompressedPoint(Data(publicKeyBytes)) else { + throw BackendError.invalidPublicKey + } + + do { + return try Secp256k1Primitives.ecdhSharedX( + privateKey: Data(privateKeyBytes), + publicKey: Data(publicKeyBytes) + ) + } catch let error as PlatformWalletError { + switch error { + case .invalidParameter: + throw BackendError.invalidPrivateKey + case .walletOperation: + throw BackendError.pointAtInfinity + default: + throw BackendError.backendUnavailable + } + } catch { + throw BackendError.backendUnavailable + } + } + + private static func validatedPrivateKeyBytes(_ privateKey: Data) throws -> [UInt8] { + guard privateKey.count == 32 else { + throw BackendError.invalidPrivateKeyLength + } + + return [UInt8](privateKey) + } + + private static func validatedCompressedPublicKeyBytes(_ publicKey: Data) throws -> [UInt8] { + guard publicKey.count == 33 else { + throw BackendError.invalidPublicKeyLength + } + + let bytes = [UInt8](publicKey) + guard bytes[0] == 0x02 || bytes[0] == 0x03 else { + throw BackendError.invalidPublicKey + } + + return bytes + } + private static func zero(_ bytes: inout [UInt8]) { + bytes.withUnsafeMutableBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return + } + baseAddress.initializeMemory(as: UInt8.self, repeating: 0, count: buffer.count) + } + } +} diff --git a/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderBanner.swift b/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderBanner.swift index 5f8e01dd6..ad7ac0da2 100644 --- a/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderBanner.swift +++ b/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderBanner.swift @@ -23,7 +23,7 @@ struct CrowdNodeBalanceReminderBanner: View { var body: some View { HStack(alignment: .top, spacing: 10) { - Image(dash: .custom("warning_triangle", bundle: .dashUIKit)) + DashIcon.SystemMessage.warningTriangle.image .resizable() .scaledToFit() .frame(width: 30, height: 30) @@ -31,11 +31,11 @@ struct CrowdNodeBalanceReminderBanner: View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 1) { Text(NSLocalizedString("You have a balance on CrowdNode", comment: "CrowdNode")) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.primaryText) Text(NSLocalizedString("These funds should be withdrawn from CrowdNode. You can transfer these funds to this wallet or via your online account on some other device.", comment: "CrowdNode")) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.secondaryText) .multilineTextAlignment(.leading) } diff --git a/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderSheet.swift b/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderSheet.swift index f6568dbcf..cdd9f772d 100644 --- a/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderSheet.swift +++ b/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderSheet.swift @@ -24,7 +24,7 @@ struct CrowdNodeBalanceReminderSheet: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - Image(dash: .custom("crowdnode.warning", bundle: .dashUIKit)) + DashIcon.Illustrations.crowdnodeWarning.image .resizable() .scaledToFit() .frame(width: 90, height: 90) @@ -35,12 +35,12 @@ struct CrowdNodeBalanceReminderSheet: View { VStack(alignment: .leading, spacing: 6) { Text(NSLocalizedString("You have a balance on CrowdNode", comment: "CrowdNode")) - .font(Font.dash.title1) + .dashFont(.title1) .foregroundStyle(Color.dash.primaryText) .multilineTextAlignment(.leading) Text(NSLocalizedString("These funds should be withdrawn from CrowdNode. You can transfer these funds to this wallet or via your online account on some other device.", comment: "CrowdNode")) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundStyle(Color.dash.secondaryText) .multilineTextAlignment(.leading) } diff --git a/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift new file mode 100644 index 000000000..f3f5f1406 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift @@ -0,0 +1,316 @@ +// +// ApproveConnectionSheet.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +struct ApproveConnectionSheet: View { + let request: ConnectionRequest + var isLoading: Bool = false + /// Why the last approve attempt failed. Shown here rather than as a screen alert: + /// an alert on the presenting screen cannot appear over this sheet, so the user + /// would otherwise see nothing at all. + var errorText: String? + var onApprove: () -> Void + var onDeny: () -> Void + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color.gray300Alpha50) + .frame(width: 36, height: 5) + .padding(.top, 6) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text( + String(format: titleFormat, resolvedAppLabel) + ) + .font(.title2) + .foregroundColor(.primaryText) + + Text(resolvedSubtitle) + .font(.subhead) + .foregroundColor(.secondaryText) + } + + if let existingConnection = request.existingConnection { + reloginNotice(existingConnection) + } + + if isRelogin { + reloginPermissionsSummary + } else { + fullPermissions + } + + if request.walletUsername != nil || request.walletIdentityId != nil { + VStack(spacing: 4) { + if let walletUsername = request.walletUsername { + DetailRow( + label: NSLocalizedString("Username", comment: "DashConnect"), + value: walletUsername + ) + } + if let walletIdentityId = request.walletIdentityId { + DetailRow( + label: NSLocalizedString("Identity", comment: "DashConnect"), + value: truncateMiddle(walletIdentityId) + ) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray300, lineWidth: 1.5) + ) + } + + Spacer(minLength: 0) + + VStack(spacing: 8) { + if let errorText { + Text(errorText) + .font(.footnote) + .foregroundColor(Color.dash.errorText) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 4) + } + + DashButton( + text: NSLocalizedString("Approve", comment: "DashConnect"), + style: .filledBlue, + size: .large, + stretch: true, + isEnabled: !isLoading, + isLoading: isLoading, + action: onApprove + ) + + DashButton( + text: NSLocalizedString("Deny", comment: "DashConnect"), + style: .tintedBlue, + size: .large, + stretch: true, + isEnabled: !isLoading, + action: onDeny + ) + } + } + // Figma uses a much wider horizontal inset here; 20pt matches the + // phone sheets already used in this iOS app and avoids over-compressing the content. + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .background(Color.primaryBackground) + } + + private var resolvedAppLabel: String { + let trimmed = request.appLabel.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : trimmed + } + + private var isRelogin: Bool { + request.existingConnection != nil + } + + private var titleFormat: String { + if isRelogin { + return NSLocalizedString("Sign in to %@ again?", comment: "DashConnect re-login") + } else { + return NSLocalizedString("Approve connection to %@?", comment: "DashConnect") + } + } + + private var resolvedSubtitle: String { + let candidates = [ + request.appUrl.trimmingCharacters(in: .whitespacesAndNewlines), + request.appLabel.trimmingCharacters(in: .whitespacesAndNewlines), + NSLocalizedString("Unknown app", comment: "DashConnect"), + ] + return candidates.first(where: { !$0.isEmpty }) ?? NSLocalizedString("Unknown app", comment: "DashConnect") + } + + private var fullPermissions: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("This app will be able to:", comment: "DashConnect")) + .font(.subhead) + .foregroundColor(.primaryText) + + PermissionRow( + icon: .custom("dashconnect.check.circle", maxHeight: 18), + text: NSLocalizedString("See your username", comment: "DashConnect") + ) + PermissionRow( + icon: .custom("dashconnect.check.circle", maxHeight: 18), + text: NSLocalizedString("Verify your identity", comment: "DashConnect") + ) + } + + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("This app will NOT have access to:", comment: "DashConnect")) + .font(.subhead) + .foregroundColor(.primaryText) + + PermissionRow( + icon: .custom("dashconnect.xmark.circle", maxHeight: 18), + text: NSLocalizedString("Your private keys", comment: "DashConnect") + ) + PermissionRow( + icon: .custom("dashconnect.xmark.circle", maxHeight: 18), + text: NSLocalizedString("Withdraw funds", comment: "DashConnect") + ) + } + } + } + + private var reloginPermissionsSummary: some View { + HStack(spacing: 8) { + Icon(name: .custom("dashconnect.check.circle", maxHeight: 18)) + .frame(width: 18, height: 18) + + Text(NSLocalizedString("Same permissions as before", comment: "DashConnect re-login")) + .font(.subhead) + .foregroundColor(.primaryText) + } + } + + private func reloginNotice(_ existingConnection: DAppConnection) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(NSLocalizedString("This connection already exists.", comment: "DashConnect re-login")) + .font(.subhead) + .foregroundColor(.primaryText) + + Text( + String( + format: NSLocalizedString("Connected since %@", comment: "DashConnect re-login"), + Self.connectedSinceFormatter.string(from: existingConnection.updatedAt) + ) + ) + .font(.footnote) + .foregroundColor(.secondaryText) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray300, lineWidth: 1.5) + ) + } + + /// Truncates a long identifier in the middle, keeping `prefix` leading and + /// `suffix` trailing characters (e.g. "5DbLwAx…7zUo8"). + fileprivate func truncateMiddle(_ value: String, prefix: Int = 7, suffix: Int = 5) -> String { + guard value.count > prefix + suffix + 1 else { return value } + return "\(value.prefix(prefix))…\(value.suffix(suffix))" + } + + private static let connectedSinceFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() +} + +private struct PermissionRow: View { + let icon: IconName + let text: String + + var body: some View { + HStack(spacing: 6) { + Icon(name: icon) + .frame(width: 18, height: 18) + + Text(text) + .font(.subhead) + .foregroundColor(.primaryText) + } + } +} + +private struct DetailRow: View { + let label: String + let value: String + + var body: some View { + HStack(spacing: 8) { + Text(label) + .font(.subhead) + .foregroundColor(.primaryText) + + Spacer(minLength: 12) + + Text(value) + .font(.subhead) + .foregroundColor(.primaryText) + .multilineTextAlignment(.trailing) + } + } +} + +#if DEBUG +private let dashConnectTruncateMiddleCheck: Bool = { + let sheet = ApproveConnectionSheet( + request: MockDashConnectDataSource.sampleRequest, + onApprove: {}, + onDeny: {} + ) + assert( + sheet.truncateMiddle("5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8") == "5DbLwAx…zUo8" + ) + return true +}() +#endif + +#Preview("Approve") { + ApproveConnectionSheet( + request: MockDashConnectDataSource.sampleRequest, + onApprove: {}, + onDeny: {} + ) +} + +#Preview("Approve Loading") { + ApproveConnectionSheet( + request: MockDashConnectDataSource.sampleRequest, + isLoading: true, + onApprove: {}, + onDeny: {} + ) +} + +#Preview("Approve Re-Login") { + ApproveConnectionSheet( + request: ConnectionRequest( + loginRequest: MockDashConnectDataSource.sampleLoginRequest, + appLabel: "Yappr", + appUrl: "yap.pr", + walletUsername: "dashuser", + walletIdentityId: "5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8", + existingConnection: MockDashConnectDataSource.sample(.active) + ), + onApprove: {}, + onDeny: {} + ) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift b/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift new file mode 100644 index 000000000..49b2d9931 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift @@ -0,0 +1,52 @@ +// +// ApproveSheetPresentation.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// Applies the approve-connection sheet's presentation options, degrading on +/// older systems. Keeping it in a modifier means the sheet content is written +/// once rather than repeated per availability branch. +struct ApproveSheetPresentation: ViewModifier { + let isLoading: Bool + + private let detentHeight: CGFloat = 620 + private let cornerRadius: CGFloat = 32 + + func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + content + .presentationDetents([.height(detentHeight)]) + .presentationCornerRadius(cornerRadius) + .interactiveDismissDisabled(isLoading) + } else if #available(iOS 16.0, *) { + content + .presentationDetents([.height(detentHeight)]) + .interactiveDismissDisabled(isLoading) + } else { + content + } + } +} + +extension View { + /// Presents this view as the approve-connection sheet body. + func approveSheetPresentation(isLoading: Bool) -> some View { + modifier(ApproveSheetPresentation(isLoading: isLoading)) + } +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift new file mode 100644 index 000000000..ca4b69ebf --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift @@ -0,0 +1,123 @@ +// +// ConnectionRow.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// One connected app: its name and url, the current status, and — once the +/// connection is live — the switch that lets this wallet forget it. +/// +/// An approved connection is not usable yet, so the row carries the prompt to +/// finish the login underneath itself rather than leaving the list to know +/// which rows need it. +struct ConnectionRow: View { + let connection: DAppConnection + let onPrimaryAction: () -> Void + let onMockScan: () -> Void + let onDisconnect: () -> Void + + private enum Layout { + /// `SwitchView` is a fixed 64×28 from the design system and sets that + /// width internally, so an outer `.frame` cannot shrink it — it would + /// only change the space reserved while the control kept overflowing. + /// Scaling is the only way to make it smaller from the outside; the + /// design ratio is preserved. + static let switchScale: CGFloat = 0.75 + static let switchWidth: CGFloat = 64 * switchScale + static let switchHeight: CGFloat = 28 * switchScale + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 12) { + identity + + ConnectionStatusBadge(status: connection.status, updatedAt: connection.updatedAt) + .fixedSize(horizontal: true, vertical: false) + + if connection.status == .active { + activeSwitch + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + + if connection.status == .approved { + ScanToCompleteBanner(onScanQR: onPrimaryAction, onMockScan: onMockScan) + } + } + } + + private var identity: some View { + VStack(alignment: .leading, spacing: 2) { + Text(connection.name) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + .lineLimit(1) + + if !connection.url.isEmpty { + Text(connection.url) + .dashFont(.footnote) + .foregroundStyle(Color.dash.tertiaryText) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + + /// Always reads as on: the row only shows it for an active connection, and + /// only the off direction is reachable from here. Turning it off locally + /// downgrades the connection back to `.approved`; it does not sign the user + /// out of the app, because the wallet has no channel to end that session. + private var activeSwitch: some View { + ZStack { + SwitchView(isOn: .constant(true)) + .allowsHitTesting(false) + .scaleEffect(Layout.switchScale) + } + .frame(width: Layout.switchWidth, height: Layout.switchHeight) + .contentShape(Rectangle()) + .onTapGesture(perform: onDisconnect) + .accessibilityElement() + .accessibilityAddTraits(.isButton) + .accessibilityLabel(Text(NSLocalizedString("Disconnect", comment: "DashConnect"))) + } +} + +#Preview { + VStack(spacing: 2) { + ForEach(ConnectionStatus.allCases, id: \.self) { status in + ConnectionRow( + connection: DAppConnection( + id: status.rawValue, + name: "A Very Long Application Name Indeed", + url: "https://example.org", + status: status, + updatedAt: Date(timeIntervalSince1970: 1_754_232_520) + ), + onPrimaryAction: {}, + onMockScan: {}, + onDisconnect: {} + ) + } + } + .padding() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift new file mode 100644 index 000000000..1883126f1 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift @@ -0,0 +1,83 @@ +// +// ConnectionStatusBadge.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// The status column of a connection row: a coloured dot with the status name, +/// above the moment that status was last reached. +struct ConnectionStatusBadge: View { + let status: ConnectionStatus + let updatedAt: Date + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "d MMM yyyy, H:mm" + return formatter + }() + + var body: some View { + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 6) { + Circle() + .fill(dotColor) + .frame(width: 8, height: 8) + + Text(title) + .dashFont(.footnote) + .foregroundColor(Color.dash.primaryText) + .lineLimit(1) + } + + Text(Self.dateFormatter.string(from: updatedAt)) + .dashFont(.footnote) + .foregroundColor(Color.dash.tertiaryText) + .lineLimit(1) + } + } + + /// Green only once the connection is live; the only other state is + /// `approved`, which reads as neutral blue while it waits for the next scan. + private var dotColor: Color { + status == .active ? Color.dash.green : Color.dash.blue + } + + private var title: String { + switch status { + case .approved: + return NSLocalizedString("Approved", comment: "DashConnect") + case .active: + return NSLocalizedString("Active", comment: "DashConnect") + } + } +} + +#Preview { + VStack(alignment: .trailing, spacing: 16) { + ForEach(ConnectionStatus.allCases, id: \.self) { status in + ConnectionStatusBadge( + status: status, + updatedAt: Date(timeIntervalSince1970: 1_754_232_520) + ) + } + } + .padding() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsEmptyState.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsEmptyState.swift new file mode 100644 index 000000000..e9ee3fa3a --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsEmptyState.swift @@ -0,0 +1,74 @@ +// +// ConnectionsEmptyState.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// Shown when the feature is available but nothing is connected yet. Explains +/// what a connection is for, then offers the only way to make one. +struct ConnectionsEmptyState: View { + let onScanQR: () -> Void + let onMockScan: () -> Void + + var body: some View { + VStack { + Spacer() + + VStack(spacing: 40) { + VStack(spacing: 20) { + HStack(spacing: 0) { + Image("dashconnect-empty") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + } + .frame(width: 80, height: 80) + + VStack(spacing: 4) { + Text(NSLocalizedString("No apps connected yet", comment: "DashConnect")) + .dashFont(.title3Medium) + .foregroundColor(Color.dash.primaryText) + + Text( + NSLocalizedString( + "Connect your DashPay username to apps on the Dash network to sign in without passwords or accounts", + comment: "DashConnect" + ) + ) + .dashFont(.subhead) + .foregroundColor(Color.dash.secondaryText) + } + .multilineTextAlignment(.center) + } + + ScanQRButton(onScanQR: onScanQR, onMockScan: onMockScan) + .padding(.horizontal, 60) + } + .padding(.horizontal, 60) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + ConnectionsEmptyState(onScanQR: {}, onMockScan: {}) + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift new file mode 100644 index 000000000..2fbfea899 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift @@ -0,0 +1,65 @@ +// +// ConnectionsList.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// The populated state: every connection stacked in one card. Rows carry their +/// own status-specific content, so this only owns the card chrome and scroll. +struct ConnectionsList: View { + let connections: [DAppConnection] + let onScanQR: () -> Void + let onMockScan: () -> Void + let onDisconnect: (DAppConnection) -> Void + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 2) { + ForEach(connections) { connection in + ConnectionRow( + connection: connection, + onPrimaryAction: onScanQR, + onMockScan: onMockScan, + onDisconnect: { onDisconnect(connection) } + ) + } + } + .modifier(MenuViewModifier()) + } + } +} + +#Preview { + ConnectionsList( + connections: ConnectionStatus.allCases.map { status in + DAppConnection( + id: status.rawValue, + name: "Example App", + url: "https://example.org", + status: status, + updatedAt: Date() + ) + }, + onScanQR: {}, + onMockScan: {}, + onDisconnect: { _ in } + ) + .padding(20) + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsUnavailableState.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsUnavailableState.swift new file mode 100644 index 000000000..f1d18473a --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsUnavailableState.swift @@ -0,0 +1,66 @@ +// +// ConnectionsUnavailableState.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// Shown when DashConnect is not offered on the current network. There is no +/// call to action — the user cannot opt in, only switch networks. +struct ConnectionsUnavailableState: View { + var body: some View { + VStack { + Spacer() + + VStack(spacing: 16) { + HStack(spacing: 0) { + Image("dashconnect-empty") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + } + .frame(width: 80, height: 80) + + VStack(spacing: 4) { + Text(NSLocalizedString("Available on testnet only", comment: "DashConnect")) + .dashFont(.title3Medium) + .foregroundColor(Color.dash.primaryText) + + Text( + NSLocalizedString( + "DashConnect login is not yet available on mainnet", + comment: "DashConnect" + ) + ) + .dashFont(.subhead) + .foregroundColor(Color.dash.secondaryText) + } + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + ConnectionsUnavailableState() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ScanQRButton.swift b/DashWallet/Sources/UI/DashConnect/Components/ScanQRButton.swift new file mode 100644 index 000000000..208fc8834 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ScanQRButton.swift @@ -0,0 +1,53 @@ +// +// ScanQRButton.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// The single "Scan QR" call to action, so the button reads the same wherever +/// it appears and the debug shortcut lives in one place. +/// +/// In debug builds a long press feeds a mock payload, which lets the connect +/// flow be exercised without a second device showing a real login QR. +struct ScanQRButton: View { + var size: DashUIKit.DashButtonSize = .large + let onScanQR: () -> Void + let onMockScan: () -> Void + + var body: some View { + DashUIKit.DashButton( + text: NSLocalizedString("Scan QR", comment: "DashConnect"), + size: size, + style: .filledBlue, + action: onScanQR + ) + #if DEBUG + .onLongPressGesture(perform: onMockScan) + #endif + } +} + +#Preview { + VStack(spacing: 20) { + ScanQRButton(onScanQR: {}, onMockScan: {}) + ScanQRButton(size: .small, onScanQR: {}, onMockScan: {}) + } + .padding() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift b/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift new file mode 100644 index 000000000..b430344e3 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift @@ -0,0 +1,63 @@ +// +// ScanToCompleteBanner.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// Prompt attached to an approved connection: approval alone does not log the +/// user in, and the same button accepts either the follow-up `dash-st:` QR or +/// a fresh `dash-key:` QR for the same app. +struct ScanToCompleteBanner: View { + let onScanQR: () -> Void + let onMockScan: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + HStack(alignment: .center, spacing: 0) { + DashIcon.SystemMessage.infoRectSmall.image + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + .frame(width: 30, height: 30, alignment: .center) + + HStack(alignment: .top, spacing: 20) { + Text( + NSLocalizedString("Scan the QR code to complete the login process", comment: "DashConnect") + ) + .dashFont(.subhead) + .foregroundColor(Color.dash.primaryText) + .frame(maxWidth: .infinity, alignment: .leading) + + ScanQRButton(size: .small, onScanQR: onScanQR, onMockScan: onMockScan) + } + .padding(.vertical, 5) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.dash.blueAlpha5) + .cornerRadius(20) + } +} + +#Preview { + ScanToCompleteBanner(onScanQR: {}, onMockScan: {}) + .padding() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift new file mode 100644 index 000000000..b3212803d --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift @@ -0,0 +1,202 @@ +// +// ConnectionsScreen.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import UIKit +import DashUIKit + +/// Lists the apps connected to this wallet. The states it can be in live in +/// `Components/`; this type owns navigation, the QR scanner and the approval +/// sheet. +struct ConnectionsScreen: View { + private let vc: UINavigationController + + @StateObject private var viewModel: ConnectionsViewModel + + init(vc: UINavigationController, viewModel: ConnectionsViewModel? = nil) { + self.vc = vc + _viewModel = StateObject(wrappedValue: viewModel ?? ConnectionsViewModel()) + } + + var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 0) { + DashUIKit.NavigationBar( + leading: { + NavigationBarElement.back.button { vc.popViewController(animated: true) } + } + ) + + TopIntro(title: NSLocalizedString("Connections", comment: "DashConnect")) + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 20) + + content + } + + if viewModel.isProcessingKeyRegistration { + Color.black.opacity(0.08) + .ignoresSafeArea() + + // The app declares its own `ProgressView: UIView`, which shadows + // SwiftUI's in any file importing both. + SwiftUI.ProgressView(NSLocalizedString("Completing DashConnect request…", comment: "DashConnect")) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color.primaryBackground) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } + .background(Color.primaryBackground) + .navigationBarHidden(true) + .alert(item: messageBinding) { message in + Alert( + title: Text(message.title), + message: Text(message.text), + dismissButton: .default(Text(NSLocalizedString("OK", comment: ""))) { + viewModel.message = nil + } + ) + } + .sheet(isPresented: isApproveSheetPresented) { + if let request = viewModel.pendingRequest { + approveSheet(for: request) + } + } + } + + // MARK: - States + + @ViewBuilder private var content: some View { + if viewModel.featureUnavailable { + ConnectionsUnavailableState() + } else if viewModel.connections.isEmpty { + ConnectionsEmptyState(onScanQR: showScanner, onMockScan: mockScan) + } else { + ConnectionsList( + connections: viewModel.connections, + onScanQR: showScanner, + onMockScan: mockScan, + onDisconnect: viewModel.disconnect + ) + .padding(.horizontal, 20) + } + } + + private func approveSheet(for request: ConnectionRequest) -> some View { + ApproveConnectionSheet( + request: request, + isLoading: viewModel.isApproving, + errorText: viewModel.approveError, + onApprove: { viewModel.approvePendingRequest() }, + onDeny: { viewModel.denyPendingRequest() } + ) + .approveSheetPresentation(isLoading: viewModel.isApproving) + } + + // MARK: - Presentation bindings + + private var isApproveSheetPresented: Binding { + Binding( + get: { viewModel.pendingRequest != nil }, + set: { isPresented in + if !isPresented && !viewModel.isApproving { + viewModel.denyPendingRequest() + } + } + ) + } + + private var messageBinding: Binding { + Binding( + get: { viewModel.message }, + set: { viewModel.message = $0 } + ) + } + + // MARK: - Actions + + private func showScanner() { + let scanner = GenericQRScannerController() + + scanner.onQRCodeScanned = { value in + vc.dismiss(animated: true) { + viewModel.onQRScanned(value) + } + } + scanner.onCancel = { + vc.dismiss(animated: true) + } + + vc.present(scanner, animated: true) + } + + private func mockScan() { + viewModel.onQRScanned(MockDashConnectDataSource.sampleLoginQRCode) + } +} + +// MARK: - Previews + +#Preview("Unavailable") { + ConnectionsScreen( + vc: UINavigationController(), + viewModel: ConnectionsViewModel( + dataSource: MockDashConnectDataSource(), + featureUnavailable: true + ) + ) +} + +#Preview("Empty") { + ConnectionsScreen( + vc: UINavigationController(), + viewModel: ConnectionsViewModel( + dataSource: MockDashConnectDataSource(), + featureUnavailable: false + ) + ) +} + +#Preview("Approved") { + ConnectionsScreen( + vc: UINavigationController(), + viewModel: ConnectionsViewModel( + dataSource: MockDashConnectDataSource(initial: [.init( + id: MockDashConnectDataSource.sample(.approved).id, + name: MockDashConnectDataSource.sample(.approved).name, + url: MockDashConnectDataSource.sample(.approved).url, + status: .approved, + updatedAt: MockDashConnectDataSource.sample(.approved).updatedAt + )]), + featureUnavailable: false + ) + ) +} + +#Preview("Active") { + ConnectionsScreen( + vc: UINavigationController(), + viewModel: ConnectionsViewModel( + dataSource: MockDashConnectDataSource(initial: [MockDashConnectDataSource.sample(.active)]), + featureUnavailable: false + ) + ) +} diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift new file mode 100644 index 000000000..bf7b61c78 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift @@ -0,0 +1,168 @@ +// +// ConnectionsViewModel.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +struct ConnectionsScreenMessage: Identifiable, Equatable { + enum Kind: String { + case error + case success + } + + let kind: Kind + let text: String + + var id: String { + "\(kind.rawValue):\(text)" + } + + var title: String { + switch kind { + case .error: + return NSLocalizedString("Error", comment: "") + case .success: + return NSLocalizedString("Success", comment: "DashConnect") + } + } +} + +@MainActor +final class ConnectionsViewModel: ObservableObject { + @Published private(set) var connections: [DAppConnection] = [] + @Published private(set) var featureUnavailable: Bool + @Published var pendingRequest: ConnectionRequest? + @Published var isApproving = false + @Published var isProcessingKeyRegistration = false + @Published var message: ConnectionsScreenMessage? + /// Failure of the last approve attempt, rendered **inside** the approve sheet. + /// A screen-level `.alert` cannot appear over a presented sheet, so routing this + /// through `message` would leave the user with no feedback at all. + @Published var approveError: String? + + private let dataSource: any DashConnectDataSource + private var pendingLoginRequest: DashKeyRequest? + private var cancellables = Set() + + init( + dataSource: (any DashConnectDataSource)? = nil, + featureUnavailable: Bool? = nil + ) { + let computedFeatureUnavailable = featureUnavailable ?? !WalletEnvironment.isTestnet + self.featureUnavailable = computedFeatureUnavailable + self.dataSource = dataSource ?? Self.defaultDataSource(featureUnavailable: computedFeatureUnavailable) + + self.dataSource.connections + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.connections = $0.sorted(by: { $0.updatedAt > $1.updatedAt }) + } + .store(in: &cancellables) + } + + func onQRScanned(_ content: String) { + guard !featureUnavailable else { return } + + Task { + do { + pendingRequest = nil + pendingLoginRequest = nil + + switch try await dataSource.parseQR(content) { + case let .login(request): + pendingLoginRequest = request + pendingRequest = await dataSource.makeConnectionRequest(from: request) + case let .keyRegistration(request): + isProcessingKeyRegistration = true + defer { isProcessingKeyRegistration = false } + + try await dataSource.completeKeyRegistration(request) + message = ConnectionsScreenMessage( + kind: .success, + text: NSLocalizedString("DashConnect key registration completed.", comment: "DashConnect") + ) + } + } catch { + message = ConnectionsScreenMessage( + kind: .error, + text: String( + format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"), + error.localizedDescription + ) + ) + } + } + } + + func approvePendingRequest() { + guard pendingRequest != nil, let pendingLoginRequest, !isApproving else { return } + + isApproving = true + approveError = nil + + Task { + defer { isApproving = false } + + do { + _ = try await dataSource.approveLogin(pendingLoginRequest) + self.pendingRequest = nil + self.pendingLoginRequest = nil + self.approveError = nil + } catch { + // Keep the sheet up so the user can retry without rescanning the QR. + self.approveError = String( + format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"), + error.localizedDescription + ) + } + } + } + + func denyPendingRequest() { + guard !isApproving else { return } + pendingRequest = nil + pendingLoginRequest = nil + approveError = nil + } + + func disconnect(_ connection: DAppConnection) { + Task { + await dataSource.disconnect(id: connection.id) + } + } + + func removeConnection(_ connection: DAppConnection) { + Task { + await dataSource.remove(id: connection.id) + } + } + + private static func defaultDataSource(featureUnavailable: Bool) -> any DashConnectDataSource { + let isPreview = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + if isPreview || featureUnavailable { + return MockDashConnectDataSource() + } + + assert( + WalletEnvironment.isTestnet, + "DashConnect real data source must only run on testnet." + ) + return PlatformDashConnectDataSource() + } +} diff --git a/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift b/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift index 4e4fde3cf..5b24aa80e 100644 --- a/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift +++ b/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift @@ -138,14 +138,14 @@ final class CoinbaseMetadataProvider: MetadataProvider, @unchecked Sendable { ) -> TxRowMetadata { TxRowMetadata( icon: icon, - iconName: .custom("transaction-coinbase.received", bundle: .dashUIKit), + iconName: .custom(DashIcon.Transaction.coinbaseReceived.assetName, bundle: .dashUIKit), secondaryIcon: transaction.direction == .sent - ? .custom("additional-info-sent", bundle: .dashUIKit) - : .custom("additional-info-received", bundle: .dashUIKit) + ? .custom(DashIcon.AdditionalInfo.sent.assetName, bundle: .dashUIKit) + : .custom(DashIcon.AdditionalInfo.received.assetName, bundle: .dashUIKit) ) } private func coinbaseIcon() -> UIImage? { - UIImage(named: "transaction-coinbase.received", in: .dashUIKit, compatibleWith: nil) + UIImage(named: DashIcon.Transaction.coinbaseReceived.assetName, in: .dashUIKit, compatibleWith: nil) } } diff --git a/DashWallet/Sources/UI/Home/Tx Metadata/GiftCardMetadataProvider.swift b/DashWallet/Sources/UI/Home/Tx Metadata/GiftCardMetadataProvider.swift index cdc6a9892..dc26b6323 100644 --- a/DashWallet/Sources/UI/Home/Tx Metadata/GiftCardMetadataProvider.swift +++ b/DashWallet/Sources/UI/Home/Tx Metadata/GiftCardMetadataProvider.swift @@ -99,11 +99,11 @@ class GiftCardMetadataProvider: MetadataProvider, @unchecked Sendable { if txRowMetadata != nil { txRowMetadata!.title = title - txRowMetadata!.secondaryIcon = .custom("transaction-gift-card", bundle: .dashUIKit) + txRowMetadata!.secondaryIcon = .custom(DashIcon.Transaction.giftCard.assetName, bundle: .dashUIKit) } else { txRowMetadata = TxRowMetadata( title: title, - secondaryIcon: .custom("transaction-gift-card", bundle: .dashUIKit) + secondaryIcon: .custom(DashIcon.Transaction.giftCard.assetName, bundle: .dashUIKit) ) } @@ -128,11 +128,11 @@ class GiftCardMetadataProvider: MetadataProvider, @unchecked Sendable { if txRowMetadata != nil { txRowMetadata!.title = title - txRowMetadata!.secondaryIcon = .custom("transaction-gift-card", bundle: .dashUIKit) + txRowMetadata!.secondaryIcon = .custom(DashIcon.Transaction.giftCard.assetName, bundle: .dashUIKit) } else { txRowMetadata = TxRowMetadata( title: title, - secondaryIcon: .custom("transaction-gift-card", bundle: .dashUIKit) + secondaryIcon: .custom(DashIcon.Transaction.giftCard.assetName, bundle: .dashUIKit) ) } diff --git a/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift b/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift index 9dd676484..f97e31fdd 100644 --- a/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift +++ b/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift @@ -125,7 +125,7 @@ class SwapOrderMetadataProvider: MetadataProvider, @unchecked Sendable { return TxRowMetadata( title: title, details: statusLabel(for: order.status), - iconName: .custom("transaction-convert", bundle: .dashUIKit), + iconName: .custom(DashIcon.Transaction.convert.assetName, bundle: .dashUIKit), secondaryIcon: secondaryIcon(for: order.status) ) } @@ -137,7 +137,7 @@ class SwapOrderMetadataProvider: MetadataProvider, @unchecked Sendable { private func secondaryIcon(for status: SwapOrderStatus) -> IconName? { switch status { case .refunded, .failed, .expired: - return .custom("additional-info-error", bundle: .dashUIKit) + return .custom(DashIcon.AdditionalInfo.error.assetName, bundle: .dashUIKit) case .notStarted, .pending, .swapping, .unknown, .completed: return nil } diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index ea52eb5b5..89485459b 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -467,7 +467,7 @@ struct HomeViewContent: View { } if txItem.transactionType == .reward { - return (.custom("transaction-mining", bundle: .dashUIKit), nil) + return (.custom(DashIcon.Transaction.mining.assetName, bundle: .dashUIKit), nil) } return (.custom(txItem.iconName), nil) diff --git a/DashWallet/Sources/UI/Menu/Settings/About/AboutDashView.swift b/DashWallet/Sources/UI/Menu/Settings/About/AboutDashView.swift index 2f0da3800..5f019d9fb 100644 --- a/DashWallet/Sources/UI/Menu/Settings/About/AboutDashView.swift +++ b/DashWallet/Sources/UI/Menu/Settings/About/AboutDashView.swift @@ -71,7 +71,7 @@ struct AboutDashView: View { VStack(alignment: .center, spacing: 2) { VStack(alignment: .center, spacing: 2) { Text(NSLocalizedString("Dash Wallet is an open sourced app forked from Bitcoin Wallet", comment: "AboutDash")) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundStyle(Color.dash.tertiaryText) .multilineTextAlignment(.center) @@ -81,7 +81,7 @@ struct AboutDashView: View { .padding(.vertical, 8) Text(NSLocalizedString("Copyright © 2026 Dash Core Group", comment: "AboutDash")) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundStyle(Color.dash.tertiaryText) .multilineTextAlignment(.center) .padding(.horizontal, 14) @@ -105,7 +105,7 @@ struct AboutDashView: View { return AnyView( Link(destination: url) { Text(viewModel.repositoryURL) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundStyle(Color.dash.blueText) .multilineTextAlignment(.center) } diff --git a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuScreen.swift b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuScreen.swift index 94ccfebe7..74600e292 100644 --- a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuScreen.swift +++ b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuScreen.swift @@ -201,6 +201,8 @@ struct ToolsMenuScreen: View { showMasternodes() case .csvExport: showCSVExportSheet = true + case .connections: + showConnections() case .zenLedger: showZenLedgerSheet = true case .storageExplorer: @@ -296,6 +298,13 @@ struct ToolsMenuScreen: View { hosting.hidesBottomBarWhenPushed = true vc.pushViewController(hosting, animated: true) } + + private func showConnections() { + let screen = ConnectionsScreen(vc: vc) + let controller = UIHostingController(rootView: screen) + controller.hidesBottomBarWhenPushed = true + vc.pushViewController(controller, animated: true) + } private func handleCSVExport() { Task { diff --git a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift index e4a9c2864..d139b4252 100644 --- a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift @@ -24,6 +24,7 @@ enum ToolsMenuNavigationDestination { case masternodeKeys case masternodes case csvExport + case connections case zenLedger case storageExplorer } @@ -131,6 +132,13 @@ class ToolsMenuViewModel: ObservableObject { self?.navigationDestination = .storageExplorer } ), + MenuItemModel( + title: NSLocalizedString("Connections", comment: "DashConnect"), + icon: .custom("menu-connections", maxHeight: 30), + action: { [weak self] in + self?.navigationDestination = .connections + } + ), MenuItemModel( title: NSLocalizedString("ZenLedger", comment: ""), subtitle: NSLocalizedString("Simplify your crypto taxes", comment: ""), diff --git a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift index 69faa759a..ad1860299 100644 --- a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift +++ b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift @@ -102,7 +102,7 @@ struct BuyEnterAmountView: View { if let statusMessage = viewModel.statusMessage { Text(statusMessage) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundStyle(Color.dash.tertiaryText) } } @@ -112,14 +112,14 @@ struct BuyEnterAmountView: View { HStack(spacing: 8) { SwiftUI.ProgressView() Text(NSLocalizedString("Checking amount…", comment: "Dash DEX")) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundStyle(Color.dash.tertiaryText) } .frame(maxWidth: .infinity, alignment: .center) .transition(.opacity) } else if let error = viewModel.inlineMessage { Text(error) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(Color.dash.red) .frame(maxWidth: .infinity, alignment: .center) .transition(.opacity) diff --git a/DashWallet/Sources/UI/Swap/Buy/Receive/BuyReceiveView.swift b/DashWallet/Sources/UI/Swap/Buy/Receive/BuyReceiveView.swift index 57e251e4c..4da9d056f 100644 --- a/DashWallet/Sources/UI/Swap/Buy/Receive/BuyReceiveView.swift +++ b/DashWallet/Sources/UI/Swap/Buy/Receive/BuyReceiveView.swift @@ -150,18 +150,18 @@ struct BuyReceiveView: View { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 2) { Text(title) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(Color.dash.secondaryText) Text(displayValue) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) if let helper { Text(helper) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(helperColor) .lineSpacing(3) .frame(maxWidth: .infinity, alignment: .leading) @@ -169,7 +169,7 @@ struct BuyReceiveView: View { } DashUIKit.DashButton( - leadingIcon: .custom("copy-outline", bundle: .dashUIKit), + leadingIcon: DashIcon.Icons.copyOutline.source, isEnabled: !copyValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, size: .medium, style: .tintedGray, action: { diff --git a/DashWallet/Sources/UI/Swap/Buy/RefundAddress/RefundAddressView.swift b/DashWallet/Sources/UI/Swap/Buy/RefundAddress/RefundAddressView.swift index 8ede724d9..31460e748 100644 --- a/DashWallet/Sources/UI/Swap/Buy/RefundAddress/RefundAddressView.swift +++ b/DashWallet/Sources/UI/Swap/Buy/RefundAddress/RefundAddressView.swift @@ -49,7 +49,7 @@ struct RefundAddressView: View { SystemMessageView( title: NSLocalizedString("The refund address is required", comment: "Dash DEX"), subtitle: NSLocalizedString("Your swap can NOT be processed without a refund address", comment: "Dash DEX"), - icon: .custom("info-rect", bundle: .dashUIKit), + icon: DashIcon.SystemMessage.infoRectSmall.source, backgroundColor: colorScheme == .dark ? Color.dash.blueAlpha10 : Color.dash.blueAlpha5, onClose: {} ) diff --git a/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift b/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift index e653b2098..a45f7b7f1 100644 --- a/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift +++ b/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift @@ -131,16 +131,16 @@ struct SwapConvertView: View { // An error (e.g. insufficient balance after coin-mode gross-up) supersedes the // receive estimate — both can be set at once, so show the error first. Text(error) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(Color.dash.red) .multilineTextAlignment(.center) } else if let amount = viewModel.receiveAmount { Text(NSLocalizedString("Receive amount", comment: "Dash DEX")) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundStyle(Color.dash.tertiaryText) Text("~ \(amount)") - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundStyle(Color.dash.primaryText) } } diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewFeeRow.swift b/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewFeeRow.swift index eaec298fe..b723baa97 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewFeeRow.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewFeeRow.swift @@ -37,7 +37,7 @@ struct OrderPreviewFeeRow: View { HStack(alignment: .top, spacing: labelSpacing) { HStack(spacing: infoSpacing) { Text(feeTitle) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) ZStack { @@ -58,7 +58,7 @@ struct OrderPreviewFeeRow: View { Text(secondary) } } - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) .frame(maxWidth: .infinity, alignment: .trailing) } diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewTableRow.swift b/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewTableRow.swift index bbf0ac88a..9104d518f 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewTableRow.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewTableRow.swift @@ -30,7 +30,7 @@ struct OrderPreviewTableRow: View { var body: some View { HStack(alignment: .top, spacing: labelSpacing) { Text(leading) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) .fixedSize() @@ -41,7 +41,7 @@ struct OrderPreviewTableRow: View { Text(secondary) } } - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity, alignment: .trailing) diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/Components/SwapFeeInfoSheet.swift b/DashWallet/Sources/UI/Swap/OrderPreview/Components/SwapFeeInfoSheet.swift index a802bc530..5b3aa864d 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/Components/SwapFeeInfoSheet.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/Components/SwapFeeInfoSheet.swift @@ -29,12 +29,12 @@ struct SwapFeeInfoSheet: View { VStack(alignment: .leading, spacing: 6) { Text(NSLocalizedString("Fees in crypto purchases", comment: "Dash DEX")) - .font(Font.dash.title1) + .dashFont(.title1) .foregroundColor(Color.dash.primaryText) .frame(maxWidth: .infinity, alignment: .topLeading) Text(infoDescription) - .font(Font.dash.body) + .dashFont(.body) .foregroundStyle(Color.dash.gray500) .multilineTextAlignment(.leading) } diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewView.swift b/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewView.swift index fff8bbb0a..8b2f28438 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewView.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewView.swift @@ -91,7 +91,7 @@ struct OrderPreviewView: View { "The final amount you receive may differ from this estimate by up to %d%% due to price slippage.", comment: "Dash DEX" ), SwapKitConstants.defaultSlippagePercent)) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(Color.dash.tertiaryText) .multilineTextAlignment(.center) .padding(.horizontal, 20) @@ -103,7 +103,7 @@ struct OrderPreviewView: View { HStack(spacing: 0) { HStack(spacing: 8) { - Image(dash: .custom("stopwatch", bundle: .dashUIKit)) + DashIcon.SystemMessage.timerSmall.image Text(NSLocalizedString("Quote expires", comment: "Dash DEX")) .dashFont(.subheadMedium) @@ -139,7 +139,7 @@ struct OrderPreviewView: View { private var fromRow: some View { HStack(spacing: Layout.labelSpacing) { Text(NSLocalizedString("From", comment: "Dash DEX")) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) .fixedSize() @@ -149,7 +149,7 @@ struct OrderPreviewView: View { .clipShape(.rect(cornerRadius: Layout.coinLogoCornerRadius)) Text(NSLocalizedString("Dash Wallet", comment: "Dash DEX")) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) } .frame(maxWidth: .infinity, alignment: .trailing) @@ -162,7 +162,7 @@ struct OrderPreviewView: View { private var toRow: some View { HStack(spacing: Layout.labelSpacing) { Text(NSLocalizedString("To", comment: "Dash DEX")) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) .fixedSize() @@ -174,7 +174,7 @@ struct OrderPreviewView: View { ) Text(viewModel.coin.name) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) } .frame(maxWidth: .infinity, alignment: .trailing) @@ -224,11 +224,11 @@ struct OrderPreviewView: View { private var totalRow: some View { HStack(alignment: .lastTextBaseline, spacing: Layout.labelSpacing) { Text(NSLocalizedString("Total", comment: "Dash DEX")) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) Text(viewModel.totalAmount) - .font(Font.dash.title3Medium) + .dashFont(.title3Medium) .foregroundColor(Color.dash.primaryText) .frame(maxWidth: .infinity, alignment: .trailing) } diff --git a/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift b/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift index 667ce4c7c..7d585df5e 100644 --- a/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift +++ b/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift @@ -60,7 +60,7 @@ struct SelectCoinView: View { VStack(spacing: 0) { DashUIKit.NavigationBar( leading: { DashUIKit.NavigationBarElement.back.button { onBack?() } }, - central: { Text(NSLocalizedString("Select coin", comment: "Dash DEX")).font(Font.dash.subheadMedium) } + central: { Text(NSLocalizedString("Select coin", comment: "Dash DEX")).dashFont(.subheadMedium) } ) mainContent } @@ -159,7 +159,7 @@ struct SelectCoinView: View { VStack { VStack(spacing: Layout.emptyStateSpacing) { Text(NSLocalizedString("No coins found", comment: "Dash DEX")) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundColor(Color.dash.gray500) } .padding(10) diff --git a/DashWallet/Sources/UI/Swap/SwapPortalScaffold.swift b/DashWallet/Sources/UI/Swap/SwapPortalScaffold.swift index a25286c2e..54684d61d 100644 --- a/DashWallet/Sources/UI/Swap/SwapPortalScaffold.swift +++ b/DashWallet/Sources/UI/Swap/SwapPortalScaffold.swift @@ -65,10 +65,10 @@ struct SwapPortalScaffold: View { action: { guard isOnline else { return }; onBuyDash?() }, label: { DashUIKit.MenuItem( - leadingIcon: .custom("menu-receive", bundle: .dashUIKit), + leadingIcon: DashIcon.Menu.receive.source, isEnabled: isOnline, disabledLeadingIcon: disabledMenuIcon( - assetName: "menu-receive.disabled", + assetName: DashIcon.Menu.receiveDisabled.assetName, fallbackName: "menu-receive" ), title: NSLocalizedString("Buy Dash", comment: "Dash DEX Portal"), @@ -87,10 +87,10 @@ struct SwapPortalScaffold: View { action: { guard isOnline else { return }; onSellDash?() }, label: { DashUIKit.MenuItem( - leadingIcon: .custom("menu-send", bundle: .dashUIKit), + leadingIcon: DashIcon.Menu.send.source, isEnabled: isOnline, disabledLeadingIcon: disabledMenuIcon( - assetName: "menu-send.disabled", + assetName: DashIcon.Menu.sendDisabled.assetName, fallbackName: "menu-send" ), title: NSLocalizedString("Sell Dash", comment: "Dash DEX Portal"), @@ -116,11 +116,11 @@ struct SwapPortalScaffold: View { private var descriptionBlock: some View { VStack(alignment: .leading, spacing: 6) { Text(title) - .font(Font.dash.title2) + .dashFont(.title2) .foregroundColor(Color.dash.primaryText) Text(description) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundColor(Color.dash.secondaryText) .multilineTextAlignment(.leading) .lineSpacing(2) diff --git a/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionFailureView.swift b/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionFailureView.swift index 2ddff5332..e1941b9ef 100644 --- a/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionFailureView.swift +++ b/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionFailureView.swift @@ -43,12 +43,12 @@ struct SwapTransactionFailureView: View { VStack(spacing: 6) { Text(NSLocalizedString("Conversion failed", comment: "Dash DEX")) - .font(Font.dash.title1) + .dashFont(.title1) .foregroundColor(Color.dash.primaryText) .multilineTextAlignment(.center) Text(reason ?? NSLocalizedString("Your DASH was not converted and no funds were deducted. Try again, or come back later.", comment: "Dash DEX")) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.secondaryText) .multilineTextAlignment(.center) .lineSpacing(3) @@ -106,7 +106,7 @@ struct SwapTransactionFailureView: View { openURL(url) } label: { Text(label) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.blue) } .buttonStyle(.plain) diff --git a/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionPendingView.swift b/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionPendingView.swift index 8c52112b8..a7d4003fe 100644 --- a/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionPendingView.swift +++ b/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionPendingView.swift @@ -39,7 +39,7 @@ struct SwapTransactionPendingView: View { .multilineTextAlignment(.center) Text(message) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.secondaryText) .multilineTextAlignment(.center) .lineSpacing(3) diff --git a/DashWallet/Sources/UI/SwapKit/SwapKitPortalView.swift b/DashWallet/Sources/UI/SwapKit/SwapKitPortalView.swift index 1498f751f..085993320 100644 --- a/DashWallet/Sources/UI/SwapKit/SwapKitPortalView.swift +++ b/DashWallet/Sources/UI/SwapKit/SwapKitPortalView.swift @@ -16,6 +16,7 @@ // import SwiftUI +import DashUIKit struct SwapKitPortalView: View { var onBack: () -> Void @@ -26,7 +27,7 @@ struct SwapKitPortalView: View { var body: some View { SwapPortalScaffold( - logoIcon: .custom("illustration-dash-dex", bundle: .dashUIKit), + logoIcon: DashIcon.Illustrations.dashDex.source, title: NSLocalizedString("Dash DEX", comment: "Dash DEX Portal"), description: NSLocalizedString( "Swap crypto into Dash, or convert Dash to any crypto supported across SwapKit networks", diff --git a/DashWalletTests/AmountObjectTests.swift b/DashWalletTests/AmountObjectTests.swift index 3000b654b..3f24243a4 100644 --- a/DashWalletTests/AmountObjectTests.swift +++ b/DashWalletTests/AmountObjectTests.swift @@ -16,7 +16,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay // MARK: - MockRatesProvider diff --git a/DashWalletTests/DWAvatarUploadClientTests.swift b/DashWalletTests/DWAvatarUploadClientTests.swift index 87deada49..8f868093a 100644 --- a/DashWalletTests/DWAvatarUploadClientTests.swift +++ b/DashWalletTests/DWAvatarUploadClientTests.swift @@ -7,7 +7,7 @@ import Moya import UIKit import XCTest -@testable import dashwallet +@testable import dashpay final class DWAvatarUploadClientTests: XCTestCase { private final class Recorder: @unchecked Sendable { diff --git a/DashWalletTests/DWContestedNameStatusServiceTests.swift b/DashWalletTests/DWContestedNameStatusServiceTests.swift index 18203eb19..915a40559 100644 --- a/DashWalletTests/DWContestedNameStatusServiceTests.swift +++ b/DashWalletTests/DWContestedNameStatusServiceTests.swift @@ -8,7 +8,7 @@ import SwiftDashSDK import XCTest -@testable import dashwallet +@testable import dashpay @MainActor final class DWContestedNameStatusServiceTests: XCTestCase { diff --git a/DashWalletTests/DWRegistrationPhaseAdapterTests.swift b/DashWalletTests/DWRegistrationPhaseAdapterTests.swift index fec5b80c8..377241873 100644 --- a/DashWalletTests/DWRegistrationPhaseAdapterTests.swift +++ b/DashWalletTests/DWRegistrationPhaseAdapterTests.swift @@ -10,7 +10,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay // NOTE: DashWalletTests links against the `dashwallet` target, but the // types under test (`DWIdentityRegistrationController`, diff --git a/DashWalletTests/DashAmountFormatterTests.swift b/DashWalletTests/DashAmountFormatterTests.swift index def8e969c..1d7ea3a17 100644 --- a/DashWalletTests/DashAmountFormatterTests.swift +++ b/DashWalletTests/DashAmountFormatterTests.swift @@ -16,7 +16,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay final class DashAmountFormatterTests: XCTestCase { diff --git a/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift b/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift new file mode 100644 index 000000000..e20063339 --- /dev/null +++ b/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift @@ -0,0 +1,253 @@ +import XCTest +@testable import dashpay + +final class DashConnectDataSourceTests: XCTestCase { + private let appPrivateKey = Data(repeating: 0x01, count: 32) + private let contractId = Data(repeating: 0xcd, count: 32) + private let label = "Login to Yappr" + + func testParseQRRoutesValidDashKeyUriToLoginRequest() async throws { + let dataSource = MockDashConnectDataSource() + + let result = try await dataSource.parseQR(validKeyUri()) + + guard case let .login(request) = result else { + return XCTFail("Expected a login request") + } + + XCTAssertEqual(request.contractId, contractId) + XCTAssertEqual(request.label, label) + XCTAssertEqual(request.network, .testnet) + } + + func testParseQRRoutesValidDashStUriToKeyRegistration() async throws { + let dataSource = MockDashConnectDataSource() + + let result = try await dataSource.parseQR(validStUri()) + + guard case let .keyRegistration(request) = result else { + return XCTFail("Expected a key registration request") + } + + XCTAssertEqual(request.transitionBytes, Data([0x01, 0x02, 0x03, 0x04])) + XCTAssertEqual(request.network, .testnet) + } + + func testParseQRRejectsJunkPayloads() async { + let dataSource = MockDashConnectDataSource() + let invalidPayloads = [ + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4ty", + "hello world", + "", + ] + + for payload in invalidPayloads { + await XCTAssertThrowsErrorAsync({ + try await dataSource.parseQR(payload) + }) { error in + XCTAssertEqual(error as? DashConnectMockError, .notDashConnectQrCode) + } + } + } + + func testParseQRRejectsWrongNetwork() async { + let dataSource = MockDashConnectDataSource(supportedNetwork: .testnet) + + await XCTAssertThrowsErrorAsync({ + try await dataSource.parseQR(validKeyUri(network: .mainnet)) + }) { error in + XCTAssertEqual( + error as? DashConnectMockError, + .unsupportedNetwork(expected: .testnet, actual: .mainnet) + ) + } + } + + func testParseQRToleratesLeadingAndTrailingWhitespace() async throws { + let dataSource = MockDashConnectDataSource() + + let result = try await dataSource.parseQR(" \n\(validKeyUri())\n\t ") + + guard case let .login(request) = result else { + return XCTFail("Expected a login request") + } + + XCTAssertEqual(request.contractId, contractId) + } + + func testCompleteKeyRegistrationThrowsOnMock() async throws { + let dataSource = MockDashConnectDataSource() + + await XCTAssertThrowsErrorAsync({ + try await dataSource.completeKeyRegistration(.init( + transitionBytes: Data([0x01, 0x02, 0x03, 0x04]), + network: .testnet + )) + }) { error in + XCTAssertEqual(error as? DashConnectMockError, .keyRegistrationNotSupported) + } + } + + func testMakeConnectionRequestCarriesExistingConnectionForMatchingContract() async { + let existing = MockDashConnectDataSource.sample(.active) + let dataSource = MockDashConnectDataSource(initial: [existing]) + + let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(request.existingConnection, existing) + } + + func testMakeConnectionRequestCarriesApprovedConnection() async { + let existing = MockDashConnectDataSource.sample(.approved) + let dataSource = MockDashConnectDataSource(initial: [existing]) + + let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(request.existingConnection, existing) + } + + func testApprovingSameAppTwiceReplacesExistingRow() async throws { + let initialDate = Date(timeIntervalSince1970: 1_773_132_300) + let existing = DAppConnection( + id: MockDashConnectDataSource.sample(.approved).id, + name: "Yappr", + url: "yap.pr", + status: .approved, + updatedAt: initialDate + ) + let dataSource = MockDashConnectDataSource(initial: [existing]) + + _ = try await dataSource.approveLogin(MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(dataSource.connectionsSnapshot.count, 1) + XCTAssertEqual(dataSource.connectionsSnapshot.first?.id, existing.id) + XCTAssertTrue((dataSource.connectionsSnapshot.first?.updatedAt ?? .distantPast) > initialDate) + } + + private func validKeyUri(network: DashConnectNetwork = .testnet) -> String { + let labelData = Data(label.utf8) + let payload = data([ + Data([0x01]), + try! validEphemeralPublicKey(), + contractId, + Data([UInt8(labelData.count)]), + labelData, + ]) + return "dash-key:\(base58Encode(payload))?n=\(network.rawValue)&v=1" + } + + private func validStUri(network: DashConnectNetwork = .testnet) -> String { + "dash-st:\(base58Encode(Data([0x01, 0x02, 0x03, 0x04])))?n=\(network.rawValue)&v=1" + } + + private func validEphemeralPublicKey() throws -> Data { + try Secp256k1.compressedPublicKey(privateKey: appPrivateKey) + } + + private func base58Encode(_ data: Data) -> String { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + let bytes = [UInt8](data) + var leadingZeros = 0 + while leadingZeros < bytes.count, bytes[leadingZeros] == 0 { + leadingZeros += 1 + } + + let size = (bytes.count - leadingZeros) * 138 / 100 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< bytes.count { + var carry = Int(bytes[i]) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 256 * Int(buffer[j]) + buffer[j] = UInt8(carry % 58) + carry /= 58 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = [UInt8]() + result.reserveCapacity(leadingZeros + size - start) + result.append(contentsOf: repeatElement(alphabet[0], count: leadingZeros)) + for i in start ..< size { + result.append(alphabet[Int(buffer[i])]) + } + return String(decoding: result, as: UTF8.self) + } + + private func data(_ chunks: [Data]) -> Data { + var result = Data() + for chunk in chunks { + result.append(chunk) + } + return result + } + + private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> T, + _ handler: (Error) -> Void + ) async { + do { + _ = try await expression() + XCTFail("Expected error to be thrown") + } catch { + handler(error) + } + } +} + +// MARK: - Branding is keyed on the contract id, not the spoofable label + +extension DashConnectDataSourceTests { + /// Yappr's data contract on testnet, taken from a real login QR. + private static let yapprContractId = Data([ + 0xc8, 0xb1, 0x02, 0xb5, 0x6e, 0xa7, 0xbb, 0xac, + 0xf7, 0x72, 0xd8, 0x36, 0x23, 0xf8, 0xd1, 0x8c, + 0x53, 0xae, 0xf2, 0x07, 0x66, 0x59, 0x9a, 0xc6, + 0x82, 0xb6, 0xa0, 0x19, 0xf2, 0x9d, 0x35, 0x3e + ]) + + private func makeLoginRequest(contractId: Data, label: String) throws -> DashKeyRequest { + DashKeyRequest( + appEphemeralPubKey: try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x01, count: 32)), + contractId: contractId, + label: label, + network: .testnet + ) + } + + /// A QR may claim any label it likes. Branding must follow the contract id, otherwise + /// an attacker's contract renders with Yappr's name and website. + func testBrandingIgnoresSpoofedLabelOnUnknownContract() throws { + let request = try makeLoginRequest( + contractId: Data(repeating: 0x11, count: 32), + label: "Login to Yappr" + ) + + let connectionRequest = ConnectionRequest(loginRequest: request) + + XCTAssertEqual(connectionRequest.appLabel, "Login to Yappr") + XCTAssertEqual( + connectionRequest.appUrl, + "", + "An unknown contract must not borrow a known app's website." + ) + } + + /// The real Yappr contract resolves to its branding whatever the label says. + func testBrandingResolvesKnownContractRegardlessOfLabel() throws { + let request = try makeLoginRequest( + contractId: Self.yapprContractId, + label: "anything at all" + ) + + let connectionRequest = ConnectionRequest(loginRequest: request) + + XCTAssertEqual(connectionRequest.appContractId, "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + XCTAssertEqual(connectionRequest.appLabel, "Yappr") + XCTAssertEqual(connectionRequest.appUrl, "yap.pr") + } +} diff --git a/DashWalletTests/DashConnect/DashConnectStoreTests.swift b/DashWalletTests/DashConnect/DashConnectStoreTests.swift new file mode 100644 index 000000000..38ee3b6d0 --- /dev/null +++ b/DashWalletTests/DashConnect/DashConnectStoreTests.swift @@ -0,0 +1,206 @@ +import XCTest +@testable import dashpay + +final class DashConnectStoreTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "DashConnectStoreTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testRoundTripPreservesConnections() { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let connections = [ + sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 10)), + sampleConnection(id: "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", name: "DashGet", url: "dashget.app", status: .active, updatedAt: Date(timeIntervalSince1970: 20)), + sampleConnection(id: "AaC695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNgA9Z", name: "PayKit", url: "paykit.dev", status: .approved, updatedAt: Date(timeIntervalSince1970: 30)), + ] + + store.save(connections) + + XCTAssertEqual(store.load(), connections) + } + + func testUnknownStatusDropsOnlyThatRow() throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let payload: [[String: Any]] = [ + [ + "contractId": "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + "label": "Yappr", + "url": "yap.pr", + "status": "approved", + "updatedAt": 1_773_132_300_000 + ], + [ + "contractId": "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", + "label": "DashGet", + "url": "dashget.app", + "status": "mystery", + "updatedAt": 1_773_218_700_000 + ], + ] + + defaults.set(try JSONSerialization.data(withJSONObject: payload), forKey: store.storageKey) + + XCTAssertEqual( + store.load(), + [sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 1_773_132_300))] + ) + } + + func testMalformedRowIsDropped() throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let payload: [[String: Any]] = [ + [ + "contractId": "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + "label": "Yappr", + "url": "yap.pr", + "status": "approved", + "updatedAt": 1_773_132_300_000 + ], + [ + "label": "Broken", + "url": "broken.app", + "status": "approved", + "updatedAt": 1_773_132_300_000 + ], + ] + + defaults.set(try JSONSerialization.data(withJSONObject: payload), forKey: store.storageKey) + + XCTAssertEqual(store.load().count, 1) + XCTAssertEqual(store.load().first?.id, "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + } + + func testLegacyDisconnectedStatusIsDropped() throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let payload: [[String: Any]] = [[ + "contractId": "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + "label": "Yappr", + "url": "yap.pr", + "status": "disconnected", + "updatedAt": 1_773_132_300_000 + ]] + + defaults.set(try JSONSerialization.data(withJSONObject: payload), forKey: store.storageKey) + + XCTAssertEqual(store.load(), []) + } + + func testEmptyStoreLoadsAsEmptyArray() { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + XCTAssertEqual(store.load(), []) + } + + func testSavingEmptyArrayClearsStore() { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + store.save([sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 10))]) + + store.save([]) + + XCTAssertNil(defaults.data(forKey: store.storageKey)) + XCTAssertEqual(store.load(), []) + } + + func testNetworksAreIsolated() { + let testnetStore = makeStore(network: .testnet, walletIdHex: "wallet-a") + let mainnetStore = makeStore(network: .mainnet, walletIdHex: "wallet-a") + let testnetConnection = sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 10)) + let mainnetConnection = sampleConnection(id: "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", status: .active, updatedAt: Date(timeIntervalSince1970: 20)) + + testnetStore.save([testnetConnection]) + mainnetStore.save([mainnetConnection]) + + XCTAssertEqual(testnetStore.load(), [testnetConnection]) + XCTAssertEqual(mainnetStore.load(), [mainnetConnection]) + XCTAssertNotEqual(testnetStore.storageKey, mainnetStore.storageKey) + XCTAssertTrue(testnetStore.storageKey.contains(".t.")) + XCTAssertTrue(mainnetStore.storageKey.contains(".m.")) + } + + func testWalletIdsAreIsolated() { + let walletAStore = makeStore(network: .testnet, walletIdHex: "wallet-a") + let walletBStore = makeStore(network: .testnet, walletIdHex: "wallet-b") + let walletAConnection = sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 10)) + + walletAStore.save([walletAConnection]) + + XCTAssertEqual(walletAStore.load(), [walletAConnection]) + XCTAssertEqual(walletBStore.load(), []) + XCTAssertNotEqual(walletAStore.storageKey, walletBStore.storageKey) + } + + func testApproveLoginPersistsAcrossFreshMockInstances() async throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let first = MockDashConnectDataSource(store: store) + + _ = try await first.approveLogin(MockDashConnectDataSource.sampleLoginRequest) + + let second = MockDashConnectDataSource(store: store) + + XCTAssertEqual(second.connectionsSnapshot.count, 1) + XCTAssertEqual(second.connectionsSnapshot.first?.id, "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + XCTAssertEqual(second.connectionsSnapshot.first?.status, .approved) + } + + func testRemovePersistsAcrossFreshMockInstances() async throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let first = MockDashConnectDataSource(store: store) + _ = try await first.approveLogin(MockDashConnectDataSource.sampleLoginRequest) + + await first.remove(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + + let second = MockDashConnectDataSource(store: store) + XCTAssertEqual(second.connectionsSnapshot, []) + } + + func testDisconnectPersistsApprovedStatusAcrossFreshMockInstances() async throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let first = MockDashConnectDataSource( + initial: [ + sampleConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + status: .active, + updatedAt: Date(timeIntervalSince1970: 10) + ) + ], + store: store + ) + + await first.disconnect(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + + let second = MockDashConnectDataSource(store: store) + XCTAssertEqual(second.connectionsSnapshot.count, 1) + XCTAssertEqual(second.connectionsSnapshot.first?.id, "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + XCTAssertEqual(second.connectionsSnapshot.first?.status, .approved) + } + + private func makeStore(network: DashConnectNetwork, walletIdHex: String?) -> UserDefaultsDashConnectStore { + UserDefaultsDashConnectStore( + defaults: defaults, + network: network, + walletIdHexProvider: { walletIdHex } + ) + } + + private func sampleConnection( + id: String, + name: String = "Yappr", + url: String = "yap.pr", + status: ConnectionStatus, + updatedAt: Date + ) -> DAppConnection { + DAppConnection(id: id, name: name, url: url, status: status, updatedAt: updatedAt) + } +} diff --git a/DashWalletTests/DashConnect/DashConnectUriTests.swift b/DashWalletTests/DashConnect/DashConnectUriTests.swift new file mode 100644 index 000000000..95761083b --- /dev/null +++ b/DashWalletTests/DashConnect/DashConnectUriTests.swift @@ -0,0 +1,199 @@ +import XCTest +@testable import dashpay + +final class DashConnectUriTests: XCTestCase { + private let appPrivateKey = Data(repeating: 0x01, count: 32) + private let contractId = Data(repeating: 0xcd, count: 32) + private let label = "Login to Yappr" + + func testParsesValidDashKeyRequest() throws { + let request = try DashConnectUri.parseKeyRequest(validKeyUri()) + + XCTAssertEqual(request.network, .testnet) + XCTAssertEqual( + request.appEphemeralPubKey.hexEncodedString(), + "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f" + ) + XCTAssertEqual(request.contractId, contractId) + XCTAssertEqual(request.label, label) + } + + func testParsesValidDashStRequest() throws { + let request = try DashConnectUri.parseStRequest(validStUri()) + XCTAssertEqual(request.network, .testnet) + XCTAssertEqual(request.transitionBytes, Data([0x01, 0x02, 0x03, 0x04])) + } + + func testRecognizesUriKinds() { + XCTAssertTrue(DashConnectUri.isKeyUri(validKeyUri())) + XCTAssertFalse(DashConnectUri.isKeyUri(validStUri())) + XCTAssertTrue(DashConnectUri.isStUri(validStUri())) + XCTAssertFalse(DashConnectUri.isStUri(validKeyUri())) + } + + func testParsesAllSupportedNetworks() throws { + XCTAssertEqual(try DashConnectUri.parseKeyRequest(validKeyUri(network: .mainnet)).network, .mainnet) + XCTAssertEqual(try DashConnectUri.parseKeyRequest(validKeyUri(network: .testnet)).network, .testnet) + XCTAssertEqual(try DashConnectUri.parseKeyRequest(validKeyUri(network: .devnet)).network, .devnet) + } + + func testRejectsWrongScheme() { + assertKeyError("dash:123?n=t&v=1", .invalidKeyScheme) + assertStError("dash:123?n=t&v=1", .invalidStScheme) + } + + func testRejectsAuthorityComponent() { + assertKeyError("dash-key://abc?n=t&v=1", .unexpectedAuthority) + assertStError("dash-st://abc?n=t&v=1", .unexpectedAuthority) + } + + func testRejectsMissingQueryAndEmptyBody() { + assertKeyError("dash-key:abc", .missingQuery) + assertKeyError("dash-key:?n=t&v=1", .emptyBody) + assertStError("dash-st:abc", .missingQuery) + assertStError("dash-st:?n=t&v=1", .emptyBody) + } + + func testRejectsMissingNetworkAndMissingVersion() { + assertKeyError(validKeyUri(query: "v=1"), .missingNetwork) + assertKeyError(validKeyUri(query: "n=t"), .missingVersion) + assertStError(validStUri(query: "v=1"), .missingNetwork) + assertStError(validStUri(query: "n=t"), .missingVersion) + } + + func testRejectsUnsupportedVersionAndUnknownNetwork() { + assertKeyError(validKeyUri(query: "n=t&v=2"), .unsupportedVersion) + assertKeyError(validKeyUri(query: "n=x&v=1"), .unknownNetwork) + assertStError(validStUri(query: "n=t&v=2"), .unsupportedVersion) + assertStError(validStUri(query: "n=x&v=1"), .unknownNetwork) + } + + func testRejectsInvalidBase58() { + assertKeyError("dash-key:0OIl?n=t&v=1", .invalidBase58) + assertStError("dash-st:0OIl?n=t&v=1", .invalidBase58) + } + + func testRejectsShortKeyPayloadAndWrongPayloadVersion() { + assertKeyError(keyUri(payload: Data(repeating: 0x00, count: 66)), .keyPayloadTooShort) + + var payload = validKeyPayload() + payload[0] = 0x02 + assertKeyError(keyUri(payload: payload), .invalidKeyPayloadVersion) + } + + func testRejectsTooLongAndOverrunningLabelLengths() { + var tooLong = validKeyPayload() + tooLong[66] = 65 + assertKeyError(keyUri(payload: tooLong), .keyLabelTooLong) + + var overrunning = validKeyPayload() + overrunning[66] = UInt8(label.utf8.count + 1) + assertKeyError(keyUri(payload: overrunning), .keyLabelLengthOverrunsPayload) + } + + func testRejectsInvalidEphemeralPoint() { + var payload = validKeyPayload() + payload.replaceSubrange(1 ..< 34, with: data([Data([0x02]), Data(repeating: 0x00, count: 32)])) + assertKeyError(keyUri(payload: payload), .invalidEphemeralPublicKey) + } + + func testAllowsEmptyLabel() throws { + let ephemeralKey = try validEphemeralPublicKey() + let payload = Data([0x01]) + ephemeralKey + contractId + Data([0x00]) + let request = try DashConnectUri.parseKeyRequest(keyUri(payload: payload)) + XCTAssertEqual(request.label, "") + } + + func testRejectsEmptyTransitionBody() { + assertStError("dash-st:?n=t&v=1", .emptyBody) + } + + private func assertKeyError(_ uri: String, _ expected: DashConnectUriError) { + XCTAssertThrowsError(try DashConnectUri.parseKeyRequest(uri)) { error in + XCTAssertEqual(error as? DashConnectUriError, expected) + } + } + + private func assertStError(_ uri: String, _ expected: DashConnectUriError) { + XCTAssertThrowsError(try DashConnectUri.parseStRequest(uri)) { error in + XCTAssertEqual(error as? DashConnectUriError, expected) + } + } + + private func validKeyUri(network: DashConnectNetwork = .testnet, query: String? = nil) -> String { + keyUri(payload: validKeyPayload(), network: network, query: query) + } + + private func validStUri(network: DashConnectNetwork = .testnet, query: String? = nil) -> String { + stUri(payload: Data([0x01, 0x02, 0x03, 0x04]), network: network, query: query) + } + + private func keyUri(payload: Data, + network: DashConnectNetwork = .testnet, + query: String? = nil) -> String { + "dash-key:\(base58Encode(payload))?\(query ?? "n=\(network.rawValue)&v=1")" + } + + private func stUri(payload: Data, + network: DashConnectNetwork = .testnet, + query: String? = nil) -> String { + "dash-st:\(base58Encode(payload))?\(query ?? "n=\(network.rawValue)&v=1")" + } + + private func validKeyPayload() -> Data { + let labelData = Data(label.utf8) + return data([ + Data([0x01]), + try! validEphemeralPublicKey(), + contractId, + Data([UInt8(labelData.count)]), + labelData, + ]) + } + + private func validEphemeralPublicKey() throws -> Data { + try Secp256k1.compressedPublicKey(privateKey: appPrivateKey) + } + + private func base58Encode(_ data: Data) -> String { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + let bytes = [UInt8](data) + var leadingZeros = 0 + while leadingZeros < bytes.count, bytes[leadingZeros] == 0 { + leadingZeros += 1 + } + + let size = (bytes.count - leadingZeros) * 138 / 100 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< bytes.count { + var carry = Int(bytes[i]) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 256 * Int(buffer[j]) + buffer[j] = UInt8(carry % 58) + carry /= 58 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = [UInt8]() + result.reserveCapacity(leadingZeros + size - start) + result.append(contentsOf: repeatElement(alphabet[0], count: leadingZeros)) + for i in start ..< size { + result.append(alphabet[Int(buffer[i])]) + } + return String(decoding: result, as: UTF8.self) + } + + private func data(_ chunks: [Data]) -> Data { + var result = Data() + for chunk in chunks { + result.append(chunk) + } + return result + } +} diff --git a/DashWalletTests/DashConnect/KeyExchangeCryptoTests.swift b/DashWalletTests/DashConnect/KeyExchangeCryptoTests.swift new file mode 100644 index 000000000..4fedffb53 --- /dev/null +++ b/DashWalletTests/DashConnect/KeyExchangeCryptoTests.swift @@ -0,0 +1,172 @@ +import XCTest +@testable import dashpay + +final class KeyExchangeCryptoTests: XCTestCase { + private let sharedX = Data(hex: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")! + private let loginKey = Data(hex: "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f")! + private let identityId = Data(hex: "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f")! + private let walletEphemeralPriv = Data(repeating: 0x01, count: 32) + private let appEphemeralPub = Data(hex: "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f")! + private let fixedNonce = Data(hex: "101112131415161718191a1b")! + private let kotlinAppPrivateKey = Data(repeating: 0x01, count: 32) + private let kotlinWalletPrivateKey = Data(repeating: 0x02, count: 32) + private let kotlinIdentityId = Data(repeating: 0xab, count: 32) + private let kotlinContractId = Data(repeating: 0xcd, count: 32) + private let kotlinFixedNonce = Data((0 ..< 12).map(UInt8.init)) + private let kotlinLoginKey = Data((0 ..< 32).map { UInt8(($0 * 7 + 3) & 0xff) }) + + func testDeriveAesKeyMatchesVector() throws { + let derived = try KeyExchangeCrypto.deriveAesKey(sharedX: sharedX) + XCTAssertEqual(derived.hexEncodedString(), "0d14bc5a19d237e70d82bd357114307629c262c77fcde496d51dfeaa35d124c2") + } + + func testEncryptLoginKeyWithFixedNonceMatchesVector() throws { + let payload = try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: fixedNonce + ) + + XCTAssertEqual(payload.hexEncodedString(), "101112131415161718191a1b914189444d0fee69f81c2da90b6e1f451e003d9ef6196f231601d0dbd93c523969bcb8d144051b2b9badadb6e3a80fc8") + } + + func testEncryptDecryptLoginKeyRoundTrips() throws { + let payload = try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub + ) + let decrypted = try KeyExchangeCrypto.decryptLoginKey( + payload, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub + ) + + XCTAssertEqual(decrypted, loginKey) + } + + func testPayloadHasExpectedLengthAndNoncePrefix() throws { + let payload = try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: fixedNonce + ) + + XCTAssertEqual(payload.count, 60) + XCTAssertEqual(payload.prefix(fixedNonce.count), fixedNonce) + } + + func testDerivedIdentityKeysMatchVectorsAndDifferByInfo() throws { + let auth = try KeyExchangeCrypto.deriveAuthPrivateKey(loginKey: loginKey, identityId: identityId) + let encryption = try KeyExchangeCrypto.deriveEncryptionPrivateKey(loginKey: loginKey, identityId: identityId) + + XCTAssertEqual(auth.hexEncodedString(), "8b37cb699757243ca057a2d691ab43f88479f7422729eee270327a327d0c2bdc") + XCTAssertEqual(encryption.hexEncodedString(), "3b658157640c41f675c6858794d4f184f55112c938715085470e056f2970cfa3") + XCTAssertNotEqual(auth, encryption) + } + + func testHash160MatchesKnownVector() { + let input = Data(repeating: 0x00, count: 33) + let hash = KeyExchangeCrypto.hash160(input) + + XCTAssertEqual(hash.hexEncodedString(), "29cfc6376255a78451eeb4b129ed8eacffa2feef") + } + + func testKotlinHash160VectorMatchesExpectedHex() { + let hash = KeyExchangeCrypto.hash160(Data([0x01, 0x02, 0x03])) + XCTAssertEqual(hash.hexEncodedString(), "9bc4860bb936abf262d7a51f74b4304833fee3b2") + } + + func testKotlinAuthPrivateKeyVectorMatchesExpectedHex() throws { + let auth = try KeyExchangeCrypto.deriveAuthPrivateKey( + loginKey: kotlinLoginKey, + identityId: kotlinIdentityId + ) + + XCTAssertEqual(auth.hexEncodedString(), "e06ee7ae45f257741dab7379793c854829b67171af111238c664d9fd90603706") + } + + func testKotlinEncryptionPrivateKeyVectorMatchesExpectedHex() throws { + let encryption = try KeyExchangeCrypto.deriveEncryptionPrivateKey( + loginKey: kotlinLoginKey, + identityId: kotlinIdentityId + ) + + XCTAssertEqual(encryption.hexEncodedString(), "6879c11819d1a60026adae34c296e83d13d06559ad63da41d03c44b203a90f80") + } + + func testKotlinFixedNoncePayloadHasExpectedShapeAndRoundTrips() throws { + let appEphemeralPub = try Secp256k1.compressedPublicKey(privateKey: kotlinAppPrivateKey) + let payload = try KeyExchangeCrypto.encryptLoginKey( + kotlinLoginKey, + walletEphemeralPriv: kotlinWalletPrivateKey, + appEphemeralPub: appEphemeralPub, + nonce: kotlinFixedNonce + ) + let decrypted = try KeyExchangeCrypto.decryptLoginKey( + payload, + walletEphemeralPriv: kotlinWalletPrivateKey, + appEphemeralPub: appEphemeralPub + ) + + XCTAssertEqual(payload.count, 60) + XCTAssertEqual(payload.prefix(kotlinFixedNonce.count), kotlinFixedNonce) + XCTAssertEqual(decrypted, kotlinLoginKey) + XCTAssertEqual(kotlinContractId.count, 32) + } + + func testRejectsInvalidLoginKeyLength() { + XCTAssertThrowsError(try KeyExchangeCrypto.encryptLoginKey( + Data(repeating: 0x01, count: 31), + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: fixedNonce + )) + } + + func testRejectsInvalidNonceLength() { + XCTAssertThrowsError(try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: Data(repeating: 0x00, count: 11) + )) + } + + func testRejectsInvalidIdentityIdLength() { + XCTAssertThrowsError(try KeyExchangeCrypto.deriveAuthPrivateKey( + loginKey: loginKey, + identityId: Data(repeating: 0x00, count: 31) + )) + XCTAssertThrowsError(try KeyExchangeCrypto.deriveEncryptionPrivateKey( + loginKey: loginKey, + identityId: Data(repeating: 0x00, count: 31) + )) + } + + func testRejectsInvalidPayloadLength() { + XCTAssertThrowsError(try KeyExchangeCrypto.decryptLoginKey( + Data(repeating: 0x00, count: 59), + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub + )) + } + + func testTamperedTagFailsToDecrypt() throws { + var tampered = try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: fixedNonce + ) + tampered[tampered.index(before: tampered.endIndex)] ^= 0x01 + + XCTAssertThrowsError(try KeyExchangeCrypto.decryptLoginKey( + tampered, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub + )) + } +} diff --git a/DashWalletTests/DashConnect/LoginKeyDerivationTests.swift b/DashWalletTests/DashConnect/LoginKeyDerivationTests.swift new file mode 100644 index 000000000..a9b17a962 --- /dev/null +++ b/DashWalletTests/DashConnect/LoginKeyDerivationTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import dashpay + +final class LoginKeyDerivationTests: XCTestCase { + private let chainKey = Data(hex: "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f")! + private let identityId = Data(hex: "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f")! + private let appContractId = Data(hex: "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")! + + func testDefaultKeyIndexIsZero() { + XCTAssertEqual(LoginKeyDerivation.defaultKeyIndex, 0) + } + + func testDeriveLoginKeyMatchesVector() throws { + let loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + XCTAssertEqual(loginKey.hexEncodedString(), "0c6ee8098b3618e2cebe7a5fb4a5b3307eb84f410262489fedc386eca0cd882d") + } + + func testDeriveLoginKeyIsDeterministic() throws { + let first = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + let second = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + XCTAssertEqual(first, second) + } + + func testDeriveLoginKeyChangesWhenAnyInputChanges() throws { + let baseline = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + let differentChainKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: Data(hex: "616162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f")!, + identityId: identityId, + appContractId: appContractId + ) + let differentIdentity = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: Data(hex: "414142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f")!, + appContractId: appContractId + ) + let differentContract = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: Data(hex: "818182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")! + ) + + XCTAssertNotEqual(baseline, differentChainKey) + XCTAssertNotEqual(baseline, differentIdentity) + XCTAssertNotEqual(baseline, differentContract) + } + + func testRejectsInvalidLengths() { + XCTAssertThrowsError(try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: Data(repeating: 0x00, count: 31), + identityId: identityId, + appContractId: appContractId + )) + XCTAssertThrowsError(try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: Data(repeating: 0x00, count: 31), + appContractId: appContractId + )) + XCTAssertThrowsError(try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: Data(repeating: 0x00, count: 31) + )) + } +} diff --git a/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift new file mode 100644 index 000000000..6f45b51dd --- /dev/null +++ b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift @@ -0,0 +1,911 @@ +import XCTest +@testable import dashpay +@testable import SwiftDashSDK + +final class PlatformDashConnectDataSourceTests: XCTestCase { + func testSelectDocumentSigningKeyIdPrefersHighOverMaster() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 1, securityLevel: .master), + makeSigningCandidate(keyId: 2, securityLevel: .high), + ] + ) + + XCTAssertEqual(selected, 2) + } + + func testSelectDocumentSigningKeyIdFallsBackToCriticalWhenHighMissing() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 1, securityLevel: .master), + makeSigningCandidate(keyId: 3, securityLevel: .critical), + ] + ) + + XCTAssertEqual(selected, 3) + } + + func testSelectDocumentSigningKeyIdPrefersHighOverCritical() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 1, securityLevel: .master), + makeSigningCandidate(keyId: 4, securityLevel: .critical), + makeSigningCandidate(keyId: 5, securityLevel: .high), + ] + ) + + XCTAssertEqual(selected, 5) + } + + func testSelectDocumentSigningKeyIdThrowsWhenOnlyMasterExists() { + XCTAssertThrowsError( + try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [makeSigningCandidate(keyId: 1, securityLevel: .master)] + ) + ) { error in + XCTAssertEqual(error as? DashConnectPlatformError, .noAuthenticationKey) + } + } + + func testSelectDocumentSigningKeyIdIgnoresDisabledEligibleKeys() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 1, securityLevel: .high, disabledAt: 123), + makeSigningCandidate(keyId: 2, securityLevel: .critical), + ] + ) + + XCTAssertEqual(selected, 2) + } + + func testSelectDocumentSigningKeyIdBreaksTiesByLowestKeyId() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 9, securityLevel: .high), + makeSigningCandidate(keyId: 4, securityLevel: .high), + makeSigningCandidate(keyId: 7, securityLevel: .high), + ] + ) + + XCTAssertEqual(selected, 4) + } + + func testMakeConnectionRequestCarriesExistingConnectionForMatchingContract() async { + let existing = DAppConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + name: "Yappr", + url: "yap.pr", + status: .active, + updatedAt: Date(timeIntervalSince1970: 1_773_132_300) + ) + let dataSource = PlatformDashConnectDataSource( + store: TestDashConnectStore(initialConnections: [existing]) + ) + + let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(request.existingConnection, existing) + XCTAssertEqual(request.appContractId, existing.id) + } + + func testMakeConnectionRequestCarriesApprovedConnection() async { + let existing = DAppConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + name: "Yappr", + url: "yap.pr", + status: .approved, + updatedAt: Date(timeIntervalSince1970: 1_773_132_300) + ) + let dataSource = PlatformDashConnectDataSource( + store: TestDashConnectStore(initialConnections: [existing]) + ) + + let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(request.existingConnection, existing) + } + + func testMakeConnectionRequestReturnsNilForUnknownContract() async throws { + let existing = DAppConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + name: "Yappr", + url: "yap.pr", + status: .active, + updatedAt: Date(timeIntervalSince1970: 1_773_132_300) + ) + let dataSource = PlatformDashConnectDataSource( + store: TestDashConnectStore(initialConnections: [existing]) + ) + let request = try makeLoginRequest( + contractId: Data(repeating: 0x44, count: 32), + label: "Something else" + ) + + let connectionRequest = await dataSource.makeConnectionRequest(from: request) + + XCTAssertNil(connectionRequest.existingConnection) + } + + func testRealKeyRegistrationFixtureParsesWithNilContractBounds() throws { + let wallet = ManagedPlatformWallet(handle: 0, walletId: Data(repeating: 0x00, count: 32)) + let bytes = try XCTUnwrap(Data(hex: Self.realKeyRegistrationFixtureHex)) + let expectedIdentityId = try XCTUnwrap( + Data(hex: "89fd6ddba75136a4fea02dc7d89ef0ca5bcc32ccf12fb8da6a1a03740567ae72") + ) + let expectedAuthHash160 = try XCTUnwrap( + Data(hex: "5e24e38a86e720f61757647996957e322686abb7") + ) + let expectedEncryptionPublicKey = try XCTUnwrap( + Data(hex: "035e8cfb0785b54e8902a3dc17bdaad8a5738c6019a18ebc527f79d1c64a27826a") + ) + + let parsed = try wallet.parseIdentityUpdateTransition(bytes) + + XCTAssertEqual(parsed.identityId, expectedIdentityId) + XCTAssertEqual(parsed.disablePublicKeyIds, []) + XCTAssertEqual(parsed.addPublicKeys.count, 2) + + let authKey = parsed.addPublicKeys[0] + XCTAssertEqual(authKey.keyId, 6) + XCTAssertEqual(authKey.purpose, .authentication) + XCTAssertEqual(authKey.securityLevel, .high) + XCTAssertEqual(authKey.keyType, .ecdsaHash160) + XCTAssertNil(authKey.contractBounds) + XCTAssertEqual(authKey.pubkeyBytes, expectedAuthHash160) + + let encryptionKey = parsed.addPublicKeys[1] + XCTAssertEqual(encryptionKey.keyId, 7) + XCTAssertEqual(encryptionKey.purpose, .encryption) + XCTAssertEqual(encryptionKey.securityLevel, .medium) + XCTAssertEqual(encryptionKey.keyType, .ecdsaSecp256k1) + XCTAssertNil(encryptionKey.contractBounds) + XCTAssertEqual(encryptionKey.pubkeyBytes, expectedEncryptionPublicKey) + } + + func testPlatformWalletParserRoundTripsTaggedAndTaglessIdentityUpdateTransition() throws { + let wallet = ManagedPlatformWallet(handle: 0, walletId: Data(repeating: 0x00, count: 32)) + let taggedBytes = try XCTUnwrap(Data(base64Encoded: Self.taggedIdentityUpdateFixtureBase64)) + let taglessBytes = try XCTUnwrap(Data(base64Encoded: Self.taglessIdentityUpdateFixtureBase64)) + + let tagged = try wallet.parseIdentityUpdateTransition(taggedBytes) + let tagless = try wallet.parseIdentityUpdateTransition(taglessBytes) + + XCTAssertEqual(tagged.identityId, Data(repeating: 0x11, count: 32)) + XCTAssertEqual(tagged.addPublicKeys.map(\.keyId), [17, 18]) + XCTAssertEqual(tagged.disablePublicKeyIds, [4, 8]) + XCTAssertEqual(tagged.addPublicKeys[0].purpose, .authentication) + XCTAssertEqual(tagged.addPublicKeys[1].purpose, .encryption) + XCTAssertEqual( + tagged.addPublicKeys[1].contractBounds, + .singleContractDocumentType( + id: Data(repeating: 0x44, count: 32), + documentTypeName: "profile" + ) + ) + + XCTAssertEqual(tagless.identityId, tagged.identityId) + XCTAssertEqual(tagless.addPublicKeys.map(\.keyId), tagged.addPublicKeys.map(\.keyId)) + XCTAssertEqual(tagless.disablePublicKeyIds, tagged.disablePublicKeyIds) + + let appParser = PlatformWalletDashConnectKeyRegistrationParser { bytes in + try wallet.parseIdentityUpdateTransition(bytes) + } + let appTransition = try appParser.parse(taglessBytes) + XCTAssertEqual(appTransition.identityId, tagged.identityId) + XCTAssertEqual(appTransition.addPublicKeys.map(\.keyId), [17, 18]) + XCTAssertEqual(appTransition.disablePublicKeyIds, [4, 8]) + } + + func testBuildLoginKeyResponseDraftProducesExactFieldsAndWipesEphemeralPrivateKey() throws { + let loginKey = Data(repeating: 0x11, count: 32) + let appContractId = Data(repeating: 0xcd, count: 32) + let appEphemeralPubKey = try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x01, count: 32)) + var walletEphemeralPrivateKey = Data(repeating: 0x02, count: 32) + let fixedPayload = Data(repeating: 0xaa, count: 60) + + let draft = try PlatformDashConnectDataSource.buildLoginKeyResponseDraft( + loginKey: loginKey, + appContractId: appContractId, + appEphemeralPubKey: appEphemeralPubKey, + walletEphemeralPrivateKey: &walletEphemeralPrivateKey, + encryptLoginKey: { loginKeyArg, walletPrivArg, appPubArg in + XCTAssertEqual(loginKeyArg, loginKey) + XCTAssertEqual(walletPrivArg, Data(repeating: 0x02, count: 32)) + XCTAssertEqual(appPubArg, appEphemeralPubKey) + return fixedPayload + } + ) + + XCTAssertEqual( + draft.properties["contractId"]?.base as? String, + appContractId.toBase58String() + ) + XCTAssertEqual( + draft.properties["appEphemeralPubKeyHash"]?.base as? String, + KeyExchangeCrypto.hash160(appEphemeralPubKey).toHexString() + ) + XCTAssertEqual( + draft.properties["walletEphemeralPubKey"]?.base as? String, + try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x02, count: 32)).toHexString() + ) + XCTAssertEqual( + draft.properties["encryptedPayload"]?.base as? String, + fixedPayload.toHexString() + ) + XCTAssertEqual(draft.properties["keyIndex"]?.base as? Int, LoginKeyDerivation.defaultKeyIndex) + XCTAssertEqual(draft.encryptedPayload.count, 60) + XCTAssertEqual(walletEphemeralPrivateKey, Data(repeating: 0x00, count: 32)) + } + + func testValidateKeyRegistrationRejectsTransitionForDifferentIdentity() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: Data(repeating: 0x55, count: 32), + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationWrongIdentity = error else { + return XCTFail("Expected wrong identity error, got \(error)") + } + } + } + + func testValidateKeyRegistrationRejectsAttackerAddedKey() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + var transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + let attackerPublicKey = try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x66, count: 32)) + transition = DashConnectKeyRegistrationTransition( + identityId: transition.identityId, + addPublicKeys: [ + DashConnectKeyRegistrationKey( + keyId: transition.addPublicKeys[0].keyId, + keyType: transition.addPublicKeys[0].keyType, + purpose: transition.addPublicKeys[0].purpose, + securityLevel: transition.addPublicKeys[0].securityLevel, + publicKeyData: KeyExchangeCrypto.hash160(attackerPublicKey), + contractBounds: transition.addPublicKeys[0].contractBounds + ), + transition.addPublicKeys[1], + ], + disablePublicKeyIds: [] + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationMismatchedDerivedKey(.authentication) = error else { + return XCTFail("Expected mismatched authentication key error, got \(error)") + } + } + } + + func testValidateKeyRegistrationAcceptsNilContractBoundsWhenDerivedKeysMatch() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + XCTAssertEqual(validated.authenticationKey, transition.addPublicKeys[0]) + XCTAssertEqual(validated.encryptionKey, transition.addPublicKeys[1]) + } + + func testValidateKeyRegistrationRejectsDerivedKeysForDifferentContract() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let transitionContractId = Data(repeating: 0x44, count: 32) + let actualContractId = Data(repeating: 0x55, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: transitionContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: actualContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationMismatchedDerivedKey = error else { + return XCTFail("Expected mismatched derived key error, got \(error)") + } + } + } + + func testValidateKeyRegistrationRejectsAuthenticationKeyUsingRawSecp256k1Encoding() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + var transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + let loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + let authPrivateKey = try KeyExchangeCrypto.deriveAuthPrivateKey(loginKey: loginKey, identityId: identityId) + let authPublicKey = try Secp256k1.compressedPublicKey(privateKey: authPrivateKey) + transition = DashConnectKeyRegistrationTransition( + identityId: transition.identityId, + addPublicKeys: [ + DashConnectKeyRegistrationKey( + keyId: transition.addPublicKeys[0].keyId, + keyType: .ecdsaSecp256k1, + purpose: .authentication, + securityLevel: .high, + publicKeyData: authPublicKey, + contractBounds: nil + ), + transition.addPublicKeys[1], + ], + disablePublicKeyIds: [] + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationMismatchedDerivedKey(.authentication) = error else { + return XCTFail("Expected authentication mismatch error, got \(error)") + } + } + } + + func testValidateKeyRegistrationRejectsAuthenticationKeyUsingRawPublicKeyData() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + var transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + let loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + let authPrivateKey = try KeyExchangeCrypto.deriveAuthPrivateKey(loginKey: loginKey, identityId: identityId) + let authPublicKey = try Secp256k1.compressedPublicKey(privateKey: authPrivateKey) + transition = DashConnectKeyRegistrationTransition( + identityId: transition.identityId, + addPublicKeys: [ + DashConnectKeyRegistrationKey( + keyId: transition.addPublicKeys[0].keyId, + keyType: .ecdsaHash160, + purpose: .authentication, + securityLevel: .high, + publicKeyData: authPublicKey, + contractBounds: nil + ), + transition.addPublicKeys[1], + ], + disablePublicKeyIds: [] + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationMismatchedDerivedKey(.authentication) = error else { + return XCTFail("Expected authentication mismatch error, got \(error)") + } + } + } + + func testPendingApprovedConnectionThrowsWhenNothingAwaitsKeyRegistration() { + XCTAssertThrowsError( + try PlatformDashConnectDataSource.pendingApprovedConnectionForKeyRegistration( + in: [ + DAppConnection( + id: "A", + name: "Active App", + url: "active.app", + status: .active, + updatedAt: Date(timeIntervalSince1970: 10) + ), + DAppConnection( + id: "B", + name: "Another Active App", + url: "active-two.app", + status: .active, + updatedAt: Date(timeIntervalSince1970: 20) + ), + ] + ) + ) { error in + XCTAssertEqual(error as? DashConnectPlatformError, .noApprovedConnectionAwaitingKeyRegistration) + } + } + + func testPendingApprovedConnectionErrorTellsUserToScanLoginQrFirst() { + XCTAssertThrowsError( + try PlatformDashConnectDataSource.pendingApprovedConnectionForKeyRegistration(in: []) + ) { error in + XCTAssertEqual( + error.localizedDescription, + "There is no login awaiting key registration — scan the app's login QR first." + ) + } + } + + func testPendingApprovedConnectionPrefersMostRecentApproved() throws { + let olderApproved = DAppConnection( + id: "older", + name: "Older Approved", + url: "older.app", + status: .approved, + updatedAt: Date(timeIntervalSince1970: 10) + ) + let newestApproved = DAppConnection( + id: "newest", + name: "Newest Approved", + url: "newest.app", + status: .approved, + updatedAt: Date(timeIntervalSince1970: 30) + ) + let active = DAppConnection( + id: "active", + name: "Active", + url: "active.app", + status: .active, + updatedAt: Date(timeIntervalSince1970: 40) + ) + + let selected = try PlatformDashConnectDataSource.pendingApprovedConnectionForKeyRegistration( + in: [olderApproved, active, newestApproved] + ) + + XCTAssertEqual(selected, newestApproved) + } + + func testMissingKeyRegistrationKeysReturnsEmptyWhenBothKeysAlreadyRegistered() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData + ), + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertEqual( + PlatformDashConnectDataSource.missingKeyRegistrationKeys( + validated, + currentIdentityPublicKeys: currentKeys + ), + [] + ) + } + + func testHasRegisteredLoginKeysReturnsTrueWhenBothKeysAreRegistered() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData + ), + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertTrue( + PlatformDashConnectDataSource.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentKeys + ) + ) + } + + func testMissingKeyRegistrationKeysReturnsOnlyMissingEncryptionKey() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData + ) + ] + + XCTAssertEqual( + PlatformDashConnectDataSource.missingKeyRegistrationKeys( + validated, + currentIdentityPublicKeys: currentKeys + ), + [validated.encryptionKey] + ) + } + + func testHasRegisteredLoginKeysReturnsFalseWhenAuthenticationKeyIsMissing() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertFalse( + PlatformDashConnectDataSource.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentKeys + ) + ) + } + + func testMissingKeyRegistrationKeysTreatsDisabledMatchingKeyAsMissing() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData, + disabledAt: 123 + ), + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertEqual( + PlatformDashConnectDataSource.missingKeyRegistrationKeys( + validated, + currentIdentityPublicKeys: currentKeys + ), + [validated.authenticationKey] + ) + } + + func testHasRegisteredLoginKeysReturnsFalseWhenEncryptionKeyIsMissing() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData + ), + ] + + XCTAssertFalse( + PlatformDashConnectDataSource.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentKeys + ) + ) + } + + func testHasRegisteredLoginKeysReturnsFalseWhenMatchingKeyIsDisabled() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData, + disabledAt: 123 + ), + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertFalse( + PlatformDashConnectDataSource.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentKeys + ) + ) + } + + private func makeValidTransition( + identityId: Data, + appContractId: Data, + chainKey: Data, + walletIdentityId: Data + ) throws -> DashConnectKeyRegistrationTransition { + let loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: walletIdentityId, + appContractId: appContractId + ) + let authPrivateKey = try KeyExchangeCrypto.deriveAuthPrivateKey(loginKey: loginKey, identityId: walletIdentityId) + let encryptionPrivateKey = try KeyExchangeCrypto.deriveEncryptionPrivateKey(loginKey: loginKey, identityId: walletIdentityId) + let authPublicKey = try Secp256k1.compressedPublicKey(privateKey: authPrivateKey) + + return DashConnectKeyRegistrationTransition( + identityId: identityId, + addPublicKeys: [ + DashConnectKeyRegistrationKey( + keyId: 6, + keyType: .ecdsaHash160, + purpose: .authentication, + securityLevel: .high, + publicKeyData: KeyExchangeCrypto.hash160(authPublicKey), + contractBounds: nil + ), + DashConnectKeyRegistrationKey( + keyId: 7, + keyType: .ecdsaSecp256k1, + purpose: .encryption, + securityLevel: .medium, + publicKeyData: try Secp256k1.compressedPublicKey(privateKey: encryptionPrivateKey), + contractBounds: nil + ), + ], + disablePublicKeyIds: [] + ) + } + + private func makeLoginRequest(contractId: Data, label: String) throws -> DashKeyRequest { + DashKeyRequest( + appEphemeralPubKey: try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x01, count: 32)), + contractId: contractId, + label: label, + network: .testnet + ) + } + + private func makeSigningCandidate( + keyId: UInt32, + securityLevel: SecurityLevel, + purpose: KeyPurpose = .authentication, + keyType: KeyType = .ecdsaSecp256k1, + disabledAt: Int64? = nil + ) -> PlatformDashConnectDataSource.DocumentSigningKeyCandidate { + PlatformDashConnectDataSource.DocumentSigningKeyCandidate( + keyId: keyId, + purpose: purpose, + securityLevel: securityLevel, + keyType: keyType, + disabledAt: disabledAt + ) + } + + private func makeIdentityPublicKeyInfo( + keyId: UInt32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + data: Data, + disabledAt: UInt64? = nil + ) -> ManagedIdentity.IdentityPublicKeyInfo { + ManagedIdentity.IdentityPublicKeyInfo( + keyId: Int32(bitPattern: keyId), + purpose: purpose, + securityLevel: securityLevel, + keyType: keyType, + readOnly: false, + disabledAt: disabledAt.map { Int64(bitPattern: $0) }, + data: data + ) + } + + private static let taggedIdentityUpdateFixtureBase64 = + "BgAREREREREREREREREREREREREREREREREREREREREREQcJAgARAAACAAAhAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICQaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqABIAAQIBAUREREREREREREREREREREREREREREREREREREREREREB3Byb2ZpbGUBIQMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA0G7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7uwIECAIDQZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZ" + + private static let taglessIdentityUpdateFixtureBase64 = + "ABERERERERERERERERERERERERERERERERERERERERERBwkCABEAAAIAACECAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgJBqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoAEgABAgEBREREREREREREREREREREREREREREREREREREREREREQHcHJvZmlsZQEhAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDQbu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7AgQIAgNBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZk=" + + private static let realKeyRegistrationFixtureHex = + "0089fd6ddba75136a4fea02dc7d89ef0ca5bcc32ccf12fb8da6a1a03740567ae7201010200060200020000145e24e38a86e720f61757647996957e322686abb7000007000103000021035e8cfb0785b54e8902a3dc17bdaad8a5738c6019a18ebc527f79d1c64a27826a4120dc911df1d1e6cccf8c95ec0d423c928433397933de6dd9ad006bc40dc0334d6d270d50c6d5e2dcdc5560e40487ddfe28bd1066d0729fad4b26f92ae33f12a04b00000000" +} + +private final class TestDashConnectStore: DashConnectStore { + private var connections: [DAppConnection] + + init(initialConnections: [DAppConnection] = []) { + self.connections = initialConnections + } + + func load() -> [DAppConnection] { + connections + } + + func save(_ connections: [DAppConnection]) { + self.connections = connections + } +} diff --git a/DashWalletTests/DashConnect/Secp256k1Tests.swift b/DashWalletTests/DashConnect/Secp256k1Tests.swift new file mode 100644 index 000000000..76eeacf0c --- /dev/null +++ b/DashWalletTests/DashConnect/Secp256k1Tests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import dashpay + +final class Secp256k1Tests: XCTestCase { + private enum CandidateError: Error { + case notFound + } + + private let expectedPublicKeyA = Data(hex: "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f")! + + func testCompressedPublicKeyMatchesExpectedHex() throws { + let publicKey = try Secp256k1.compressedPublicKey(privateKey: privateKeyA) + XCTAssertEqual(publicKey, expectedPublicKeyA) + } + + func testIsValidCompressedPointAcceptsKnownGoodPointAndRejectsMalformedInputs() { + XCTAssertTrue(Secp256k1.isValidCompressedPoint(expectedPublicKeyA)) + XCTAssertFalse(Secp256k1.isValidCompressedPoint(Data(expectedPublicKeyA.dropLast()))) + XCTAssertFalse(Secp256k1.isValidCompressedPoint(appending(expectedPublicKeyA, Data([0x00])))) + XCTAssertFalse(Secp256k1.isValidCompressedPoint(appending(Data([0x04]), Data(expectedPublicKeyA.dropFirst())))) + XCTAssertFalse(Secp256k1.isValidCompressedPoint(appending(Data([0x02]), Data(repeating: 0x00, count: 32)))) + } + + func testECDHIsSymmetric() throws { + let publicKeyA = try Secp256k1.compressedPublicKey(privateKey: privateKeyA) + let publicKeyB = try Secp256k1.compressedPublicKey(privateKey: privateKeyB) + + let sharedAB = try Secp256k1.ecdhSharedX(privateKey: privateKeyA, publicKey: publicKeyB) + let sharedBA = try Secp256k1.ecdhSharedX(privateKey: privateKeyB, publicKey: publicKeyA) + + XCTAssertEqual(sharedAB, sharedBA) + XCTAssertEqual(sharedAB.count, 32) + } + + func testECDHSharedXStaysLeftPaddedTo32Bytes() throws { + let candidate = try findLeadingZeroSharedXCandidate() + let shared = try Secp256k1.ecdhSharedX( + privateKey: candidate.privateKey, + publicKey: candidate.publicKey + ) + + XCTAssertEqual(shared.count, 32) + XCTAssertEqual(shared.first, 0x00) + } + + private func findLeadingZeroSharedXCandidate() throws -> (privateKey: Data, publicKey: Data) { + for a in UInt16(1)...UInt16(128) { + let privateKeyA = makePrivateKey(a) + let publicKeyA = try Secp256k1.compressedPublicKey(privateKey: privateKeyA) + + for b in UInt16(129)...UInt16(256) { + let privateKeyB = makePrivateKey(b) + let publicKeyB = try Secp256k1.compressedPublicKey(privateKey: privateKeyB) + let sharedAB = try Secp256k1.ecdhSharedX(privateKey: privateKeyA, publicKey: publicKeyB) + let sharedBA = try Secp256k1.ecdhSharedX(privateKey: privateKeyB, publicKey: publicKeyA) + + if sharedAB.first == 0x00 { + XCTAssertEqual(sharedAB, sharedBA) + return (privateKeyA, publicKeyB) + } + } + } + + XCTFail("Expected to find a deterministic ECDH pair whose shared X starts with 0x00.") + throw CandidateError.notFound + } + + private var privateKeyA: Data { + Data(repeating: 0x01, count: 32) + } + + private var privateKeyB: Data { + Data(repeating: 0x02, count: 32) + } + + private func makePrivateKey(_ value: UInt16) -> Data { + var bytes = Data(repeating: 0x00, count: 32) + bytes[30] = UInt8(value >> 8) + bytes[31] = UInt8(value & 0xff) + return bytes + } + + private func appending(_ lhs: Data, _ rhs: Data) -> Data { + var result = lhs + result.append(rhs) + return result + } +} diff --git a/DashWalletTests/DashPayIdentityKeysTests.swift b/DashWalletTests/DashPayIdentityKeysTests.swift index 8bc38eef2..6d46f3060 100644 --- a/DashWalletTests/DashPayIdentityKeysTests.swift +++ b/DashWalletTests/DashPayIdentityKeysTests.swift @@ -6,7 +6,7 @@ import Foundation import SwiftDashSDK import XCTest -@testable import dashwallet +@testable import dashpay final class DashPayIdentityKeysTests: XCTestCase { diff --git a/DashWalletTests/DiagnosticLogExporterTests.swift b/DashWalletTests/DiagnosticLogExporterTests.swift index 821091b1c..396e04a5d 100644 --- a/DashWalletTests/DiagnosticLogExporterTests.swift +++ b/DashWalletTests/DiagnosticLogExporterTests.swift @@ -30,7 +30,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay final class DiagnosticLogExporterTests: XCTestCase { private func candidate(_ stamp: String, bytes: UInt64) -> DiagnosticLogExporter.SessionCandidate { diff --git a/DashWalletTests/ExchangeAddressLookupContextTests.swift b/DashWalletTests/ExchangeAddressLookupContextTests.swift index 753e11b4d..fa549ffce 100644 --- a/DashWalletTests/ExchangeAddressLookupContextTests.swift +++ b/DashWalletTests/ExchangeAddressLookupContextTests.swift @@ -18,7 +18,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay final class ExchangeAddressLookupContextTests: XCTestCase { diff --git a/DashWalletTests/PastedAmountNormalizationTests.swift b/DashWalletTests/PastedAmountNormalizationTests.swift index 8d9b0f1a1..bc8bfd5a4 100644 --- a/DashWalletTests/PastedAmountNormalizationTests.swift +++ b/DashWalletTests/PastedAmountNormalizationTests.swift @@ -15,7 +15,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay /// Tests for `BaseAmountModel.normalizedPastedNumberString(from:)`, which makes pasting /// amounts work regardless of the decimal/grouping convention of the pasted text or the diff --git a/DashWalletTests/PaymentProtocolTests.swift b/DashWalletTests/PaymentProtocolTests.swift index e71f23545..0e7813e03 100644 --- a/DashWalletTests/PaymentProtocolTests.swift +++ b/DashWalletTests/PaymentProtocolTests.swift @@ -14,7 +14,7 @@ import XCTest import Security import SwiftDashSDK -@testable import dashwallet +@testable import dashpay final class PaymentProtocolTests: XCTestCase { diff --git a/DashWalletTests/PhraseRepairEngineTests.swift b/DashWalletTests/PhraseRepairEngineTests.swift index dbedd5838..35a55bfc3 100644 --- a/DashWalletTests/PhraseRepairEngineTests.swift +++ b/DashWalletTests/PhraseRepairEngineTests.swift @@ -20,7 +20,7 @@ import XCTest import SwiftDashSDK -@testable import dashwallet +@testable import dashpay // MARK: - Helpers diff --git a/DashWalletTests/String+DashWalletTests.swift b/DashWalletTests/String+DashWalletTests.swift index bee289c4f..eb874cae6 100644 --- a/DashWalletTests/String+DashWalletTests.swift +++ b/DashWalletTests/String+DashWalletTests.swift @@ -16,7 +16,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay // MARK: - String_DashWallet diff --git a/DashWalletTests/SwapAddressValidatorTests.swift b/DashWalletTests/SwapAddressValidatorTests.swift index b9441f919..2c8c06b3d 100644 --- a/DashWalletTests/SwapAddressValidatorTests.swift +++ b/DashWalletTests/SwapAddressValidatorTests.swift @@ -18,7 +18,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay class SwapAddressValidatorTests: XCTestCase { diff --git a/DashWalletTests/SwapKitQuoteDecodingTests.swift b/DashWalletTests/SwapKitQuoteDecodingTests.swift index 1ba20e32e..8d3fd6d96 100644 --- a/DashWalletTests/SwapKitQuoteDecodingTests.swift +++ b/DashWalletTests/SwapKitQuoteDecodingTests.swift @@ -17,7 +17,7 @@ // limitations under the License. // -@testable import dashwallet +@testable import dashpay import XCTest final class SwapKitQuoteDecodingTests: XCTestCase { diff --git a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift index 931db4bd3..cbf4c899d 100644 --- a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift +++ b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift @@ -6,7 +6,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay private enum CoreLifecycleTestError: Error { case start diff --git a/DashWalletTests/WalletWipeSerialExecutorTests.swift b/DashWalletTests/WalletWipeSerialExecutorTests.swift index 62391a41b..20137067f 100644 --- a/DashWalletTests/WalletWipeSerialExecutorTests.swift +++ b/DashWalletTests/WalletWipeSerialExecutorTests.swift @@ -8,7 +8,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay private enum WalletLifecycleTestError: Error { case walletDeletion