diff --git a/Podfile b/Podfile index f4a1b0ea66..59f2f8b663 100644 --- a/Podfile +++ b/Podfile @@ -8,7 +8,7 @@ abstract_target 'novawalletAll' do pod 'SwiftLint' pod 'R.swift', :inhibit_warnings => true pod 'SoraKeystore', '~> 1.0.0' - pod 'SoraUI', :git => 'https://github.com/ERussel/UIkit-iOS.git', :tag => '1.11.1' + pod 'SoraUI', :git => 'https://github.com/ERussel/UIkit-iOS.git', :tag => '1.12.0' pod 'RobinHood', '~> 2.6.0' pod 'SoraFoundation', :git => 'https://github.com/ERussel/Foundation-iOS.git', :tag => '1.1.0' pod 'SwiftyBeaver' diff --git a/Podfile.lock b/Podfile.lock index 7581bee044..368977c09a 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -60,38 +60,38 @@ PODS: - SoraFoundation/NotificationHandlers - SoraFoundation/ViewModel (1.1.0) - SoraKeystore (1.0.0) - - SoraUI (1.11.1): - - SoraUI/AdaptiveDesign (= 1.11.1) - - SoraUI/Animator (= 1.11.1) - - SoraUI/Camera (= 1.11.1) - - SoraUI/Controls (= 1.11.1) - - SoraUI/DetailsView (= 1.11.1) - - SoraUI/EmptyState (= 1.11.1) - - SoraUI/Helpers (= 1.11.1) - - SoraUI/LoadingView (= 1.11.1) - - SoraUI/ModalPresentation (= 1.11.1) - - SoraUI/PageLoader (= 1.11.1) - - SoraUI/PinView (= 1.11.1) - - SoraUI/Skrull (= 1.11.1) - - SoraUI/AdaptiveDesign (1.11.1) - - SoraUI/Animator (1.11.1) - - SoraUI/Camera (1.11.1) - - SoraUI/Controls (1.11.1): + - SoraUI (1.12.0): + - SoraUI/AdaptiveDesign (= 1.12.0) + - SoraUI/Animator (= 1.12.0) + - SoraUI/Camera (= 1.12.0) + - SoraUI/Controls (= 1.12.0) + - SoraUI/DetailsView (= 1.12.0) + - SoraUI/EmptyState (= 1.12.0) + - SoraUI/Helpers (= 1.12.0) + - SoraUI/LoadingView (= 1.12.0) + - SoraUI/ModalPresentation (= 1.12.0) + - SoraUI/PageLoader (= 1.12.0) + - SoraUI/PinView (= 1.12.0) + - SoraUI/Skrull (= 1.12.0) + - SoraUI/AdaptiveDesign (1.12.0) + - SoraUI/Animator (1.12.0) + - SoraUI/Camera (1.12.0) + - SoraUI/Controls (1.12.0): - SoraUI/Animator - - SoraUI/DetailsView (1.11.1): + - SoraUI/DetailsView (1.12.0): - SoraUI/Controls - - SoraUI/EmptyState (1.11.1): + - SoraUI/EmptyState (1.12.0): - SoraUI/Animator - - SoraUI/Helpers (1.11.1) - - SoraUI/LoadingView (1.11.1): + - SoraUI/Helpers (1.12.0) + - SoraUI/LoadingView (1.12.0): - SoraUI/Controls - - SoraUI/ModalPresentation (1.11.1): + - SoraUI/ModalPresentation (1.12.0): - SoraUI/Animator - SoraUI/Controls - - SoraUI/PageLoader (1.11.1) - - SoraUI/PinView (1.11.1): + - SoraUI/PageLoader (1.12.0) + - SoraUI/PinView (1.12.0): - SoraUI/Controls - - SoraUI/Skrull (1.11.1) + - SoraUI/Skrull (1.12.0) - Sourcery (1.4.1) - Starscream (4.0.8) - SubstrateSdk (1.14.0): @@ -159,7 +159,7 @@ DEPENDENCIES: - SnapKit (~> 5.0.0) - SoraFoundation (from `https://github.com/ERussel/Foundation-iOS.git`, tag `1.1.0`) - SoraKeystore (~> 1.0.0) - - SoraUI (from `https://github.com/ERussel/UIkit-iOS.git`, tag `1.11.1`) + - SoraUI (from `https://github.com/ERussel/UIkit-iOS.git`, commit `a4eb0139a27d77f11b8e5083e125c38ea82d8e5e`) - Sourcery (~> 1.4) - Starscream (from `https://github.com/ERussel/Starscream.git`, tag `4.0.8`) - SubstrateSdk (from `https://github.com/nova-wallet/substrate-sdk-ios.git`, tag `1.14.0`) @@ -213,8 +213,8 @@ EXTERNAL SOURCES: :git: https://github.com/ERussel/Foundation-iOS.git :tag: 1.1.0 SoraUI: + :commit: a4eb0139a27d77f11b8e5083e125c38ea82d8e5e :git: https://github.com/ERussel/UIkit-iOS.git - :tag: 1.11.1 Starscream: :git: https://github.com/ERussel/Starscream.git :tag: 4.0.8 @@ -244,8 +244,8 @@ CHECKOUT OPTIONS: :git: https://github.com/ERussel/Foundation-iOS.git :tag: 1.1.0 SoraUI: + :commit: a4eb0139a27d77f11b8e5083e125c38ea82d8e5e :git: https://github.com/ERussel/UIkit-iOS.git - :tag: 1.11.1 Starscream: :git: https://github.com/ERussel/Starscream.git :tag: 4.0.8 @@ -285,7 +285,7 @@ SPEC CHECKSUMS: SnapKit: 97b92857e3df3a0c71833cce143274bf6ef8e5eb SoraFoundation: 5b9d3c82d602150d2c2e65481c5eca5f5987c12c SoraKeystore: 92cff6e2a12f212dd64ed089970ff7c365247b1c - SoraUI: e5ceb2cffe40145e589aa464e2e0a8d054011e0b + SoraUI: a3c1163a95c9dd1b6758ca90eb5bda2f4639d634 Sourcery: db66600e8b285c427701821598d07cf3c7e6c476 Starscream: b676ee89781677a2d8d36029a78c970710e2d3eb SubstrateSdk: 1cb78eac5b05f2c259487f3027c3ae807f24c097 @@ -303,6 +303,6 @@ SPEC CHECKSUMS: ZMarkupParser: a92d31ba40695b790f1da5fec98c3d4505341aff ZNSTextAttachment: 4a9b4e8ee1ed087fc893ae6657dfb678f1a00340 -PODFILE CHECKSUM: f37e3724d47617fb7ce7ed5e0a583491617b5899 +PODFILE CHECKSUM: 812e09964099216590d3e74fcb350c38b2ccbd38 COCOAPODS: 1.13.0 diff --git a/novawallet.xcodeproj/project.pbxproj b/novawallet.xcodeproj/project.pbxproj index 6e119aa64f..262e0db4dc 100644 --- a/novawallet.xcodeproj/project.pbxproj +++ b/novawallet.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 06FD6F5999D57B27B29C8738 /* ParaStkStakeConfirmViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037D3CE23FFD176F4F7DABC0 /* ParaStkStakeConfirmViewFactory.swift */; }; 0754911527A21957BD25A1DA /* CommonDelegationTracksViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD1EE26234E390E938D9311 /* CommonDelegationTracksViewFactory.swift */; }; 07AB0BC861AA5F134DB9AC26 /* StartStakingInfoProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = C92B3D5B314FB3EAE65FA471 /* StartStakingInfoProtocols.swift */; }; + 07D1F0F4FAE24BED8A1CF257 /* SwapSlippagePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0816F2A4A5CC1F111E626188 /* SwapSlippagePresenter.swift */; }; 08999A79B34D287030887A7C /* StartStakingConfirmWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2876FA98B3E8F7EBCF5DEED0 /* StartStakingConfirmWireframe.swift */; }; 0909E06D5D06569554F70DD8 /* AssetsSearchInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44EAF50AAF6C23225E06C16C /* AssetsSearchInteractor.swift */; }; 09A6D92CE47636723DFC91F4 /* MessageSheetViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57535268534B154B42ED51CE /* MessageSheetViewFactory.swift */; }; @@ -39,6 +40,12 @@ 0B2B9C6E2BA2E924D6A54F4B /* CrowdloanListInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4E78D69E8EBC3EB4D01F8EF /* CrowdloanListInteractor.swift */; }; 0B48B02E973CB304B765BBC9 /* ReferendumDetailsProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ABAD23C0039AFA8351C650 /* ReferendumDetailsProtocols.swift */; }; 0B65DAE0327678679CACE0B1 /* GovernanceDelegateInfoViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E894D4633D04AD4415CE1F2 /* GovernanceDelegateInfoViewFactory.swift */; }; + 0BB2E3FF30B1700D321C526A /* TransferNetworkSelectionViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA463769D0F411429780D7D /* TransferNetworkSelectionViewFactory.swift */; }; + 0C0CB37F2AC540B200EAC516 /* AssetConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0CB37E2AC540B200EAC516 /* AssetConversion.swift */; }; + 0C0CB3822AC545A800EAC516 /* AssetConversionExtrinsicService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0CB3812AC545A800EAC516 /* AssetConversionExtrinsicService.swift */; }; + 0C0CB3852AC561FB00EAC516 /* AssetHubSwapOperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0CB3842AC561FB00EAC516 /* AssetHubSwapOperationFactory.swift */; }; + 0C0CB3882AC5688100EAC516 /* AssetConversionPallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0CB3872AC5688100EAC516 /* AssetConversionPallet.swift */; }; + 0C0CB38A2AC56A1600EAC516 /* AssetConversionPallet+Path.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0CB3892AC56A1600EAC516 /* AssetConversionPallet+Path.swift */; }; 0C12A2472AA1D96000C7FA49 /* SubqueryMultistakingTypeFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C12A2462AA1D96000C7FA49 /* SubqueryMultistakingTypeFactory.swift */; }; 0C12A2492AA35AE300C7FA49 /* RelaychainStakingValidatorFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C12A2482AA35AE300C7FA49 /* RelaychainStakingValidatorFacade.swift */; }; 0C1338102AB832B30036BCD6 /* QRImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13380F2AB832B30036BCD6 /* QRImageViewModel.swift */; }; @@ -62,6 +69,18 @@ 0C13D3212A822B220054BB6F /* StartStakingDirectConfirmWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13D3202A822B220054BB6F /* StartStakingDirectConfirmWireframe.swift */; }; 0C13D3242A823D810054BB6F /* StartStakingExtrinsicProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13D3232A823D810054BB6F /* StartStakingExtrinsicProxy.swift */; }; 0C13D3262A8275400054BB6F /* StartStakingFeeIdFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13D3252A82753F0054BB6F /* StartStakingFeeIdFactory.swift */; }; + 0C13DFC92AF4FFC200E5F355 /* SwapErrorPresentableParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFC82AF4FFC200E5F355 /* SwapErrorPresentableParams.swift */; }; + 0C13DFCB2AF6182500E5F355 /* SwapModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFCA2AF6182500E5F355 /* SwapModel.swift */; }; + 0C13DFCD2AF8A5A300E5F355 /* SwapMaxModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFCC2AF8A5A300E5F355 /* SwapMaxModel.swift */; }; + 0C13DFCF2AF8ADB300E5F355 /* BigUInt+Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFCE2AF8ADB300E5F355 /* BigUInt+Operation.swift */; }; + 0C13DFD12AF8AE3E00E5F355 /* SwapBasePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFD02AF8AE3E00E5F355 /* SwapBasePresenter.swift */; }; + 0C13DFD32AF949B400E5F355 /* BalanceViewModelFactoryFacade+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFD22AF949B400E5F355 /* BalanceViewModelFactoryFacade+Formatting.swift */; }; + 0C13DFD52AFA4F1500E5F355 /* SwapIssueCheckParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFD42AFA4F1500E5F355 /* SwapIssueCheckParams.swift */; }; + 0C13DFD72AFA50A200E5F355 /* SwapIssueViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFD62AFA50A200E5F355 /* SwapIssueViewModelFactory.swift */; }; + 0C13DFDB2AFA691400E5F355 /* SlippageConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFDA2AFA691400E5F355 /* SlippageConfig.swift */; }; + 0C13DFDD2AFA82BA00E5F355 /* AmountInputViewModel+Slippage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFDC2AFA82BA00E5F355 /* AmountInputViewModel+Slippage.swift */; }; + 0C13DFDF2AFA89EF00E5F355 /* Decimal+Percents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFDE2AFA89EF00E5F355 /* Decimal+Percents.swift */; }; + 0C13DFE12AFBBAF600E5F355 /* SwapBaseViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C13DFE02AFBBAF600E5F355 /* SwapBaseViewModelFactory.swift */; }; 0C154A0E2A45995500932C3F /* CompoundComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C154A0D2A45995500932C3F /* CompoundComparator.swift */; }; 0C17BD972A42F162004AF9E7 /* WalletViewModelObserverContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C17BD962A42F162004AF9E7 /* WalletViewModelObserverContainer.swift */; }; 0C17BD992A42F1BE004AF9E7 /* MoneyPresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C17BD982A42F1BE004AF9E7 /* MoneyPresentable.swift */; }; @@ -76,6 +95,8 @@ 0C21D77C2AB42A3500EB2DBD /* ScreenOpenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C21D77B2AB42A3500EB2DBD /* ScreenOpenService.swift */; }; 0C21D77E2AB42D4100EB2DBD /* UrlHandlingAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C21D77D2AB42D4100EB2DBD /* UrlHandlingAction.swift */; }; 0C21D7802AB432B100EB2DBD /* MainTabBarIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C21D77F2AB432B100EB2DBD /* MainTabBarIndex.swift */; }; + 0C2200692ACA80BB0067BA61 /* AssetHubSwapQuoteBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2200682ACA80BB0067BA61 /* AssetHubSwapQuoteBuilder.swift */; }; + 0C22006E2ACAAC2F0067BA61 /* AssetConversionPallet+Call.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C22006D2ACAAC2F0067BA61 /* AssetConversionPallet+Call.swift */; }; 0C29B5382A4C68A500E35C6D /* AnimationUpdatibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C29B5372A4C68A500E35C6D /* AnimationUpdatibleView.swift */; }; 0C2AA829B5CB89B39E0FA95E /* CrowdloanContributionConfirmProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF01941105BCD02536538362 /* CrowdloanContributionConfirmProtocols.swift */; }; 0C2F86802A7119D400593C01 /* AuraSessionLengthOperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2F867F2A7119D400593C01 /* AuraSessionLengthOperationFactory.swift */; }; @@ -89,6 +110,7 @@ 0C2F86962A72807E00593C01 /* NominationPoolsRewardEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2F86952A72807E00593C01 /* NominationPoolsRewardEngine.swift */; }; 0C2F86982A728EE900593C01 /* NPoolsRewardEngineFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2F86972A728EE900593C01 /* NPoolsRewardEngineFactory.swift */; }; 0C2F869A2A72948100593C01 /* NominationPoolsApyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2F86992A72948100593C01 /* NominationPoolsApyTests.swift */; }; + 0C2FDF192AEB82AA006A6C59 /* AssetHubFeeModelBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2FDF182AEB82AA006A6C59 /* AssetHubFeeModelBuilder.swift */; }; 0C3205BB2A8679F0002EB914 /* EvmGasPriceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3205BA2A8679F0002EB914 /* EvmGasPriceProvider.swift */; }; 0C3205BE2A867A9C002EB914 /* EvmLegacyGasPriceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3205BD2A867A9C002EB914 /* EvmLegacyGasPriceProvider.swift */; }; 0C3205C02A867DD6002EB914 /* EvmMaxPriorityGasPriceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3205BF2A867DD6002EB914 /* EvmMaxPriorityGasPriceProvider.swift */; }; @@ -116,6 +138,10 @@ 0C40520C2A53DC4100B3E6EC /* OverlayBlurBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C40520B2A53DC4100B3E6EC /* OverlayBlurBackgroundView.swift */; }; 0C463FC82A58126A003E71C9 /* UIView+MotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C463FC72A58126A003E71C9 /* UIView+MotionEffect.swift */; }; 0C463FD02A592ACD003E71C9 /* PartialInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C463FCF2A592ACD003E71C9 /* PartialInterpolatingMotionEffect.swift */; }; + 0C500B1F2B04EA9100ABEE70 /* AssetConversionPallet+Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C500B1E2B04EA9100ABEE70 /* AssetConversionPallet+Event.swift */; }; + 0C500B222B05102900ABEE70 /* AssetTxPaymentPallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C500B212B05102900ABEE70 /* AssetTxPaymentPallet.swift */; }; + 0C500B242B0511F400ABEE70 /* ExtrinsicProcessor+CustomFee.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C500B232B0511F400ABEE70 /* ExtrinsicProcessor+CustomFee.swift */; }; + 0C500B272B07314000ABEE70 /* ExtrinsicProcessingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C500B262B07314000ABEE70 /* ExtrinsicProcessingResult.swift */; }; 0C5364A02A4D6EB700990478 /* AssetListBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C53649F2A4D6EB700990478 /* AssetListBuilder.swift */; }; 0C543E972AAB1B350035F45F /* ElectedAndPrefValidators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C543E962AAB1B350035F45F /* ElectedAndPrefValidators.swift */; }; 0C56B29DBA5245728AF7EDA4 /* GovernanceEditDelegationTracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B18E8361691E548ABAB33EA4 /* GovernanceEditDelegationTracksViewController.swift */; }; @@ -195,6 +221,7 @@ 0C9951CA2AE2ABA400B65615 /* AssetListBannerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9951C92AE2ABA400B65615 /* AssetListBannerCell.swift */; }; 0C9951CF2AE2BAE500B65615 /* PromotionBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9951CE2AE2BAE500B65615 /* PromotionBannerView.swift */; }; 0C9951D32AE2DB0200B65615 /* PromotionViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9951D22AE2DB0200B65615 /* PromotionViewModelFactory.swift */; }; + 0C9A7F992AFC9A2B00938CD0 /* SwapPriceDifferenceConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9A7F982AFC9A2B00938CD0 /* SwapPriceDifferenceConfig.swift */; }; 0C9C642D2A8CE30A004DC078 /* SystemAccountValidating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9C642C2A8CE30A004DC078 /* SystemAccountValidating.swift */; }; 0C9C64302A8D6779004DC078 /* StakingNPoolsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9C642F2A8D6779004DC078 /* StakingNPoolsPresenter.swift */; }; 0C9C64322A8D67A0004DC078 /* StakingNPoolsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9C64312A8D67A0004DC078 /* StakingNPoolsInteractor.swift */; }; @@ -202,8 +229,15 @@ 0C9C64362A8D67FB004DC078 /* StakingNPoolsProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9C64352A8D67FB004DC078 /* StakingNPoolsProtocols.swift */; }; 0C9C64382A8D6949004DC078 /* NPoolsStakingSharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9C64372A8D6949004DC078 /* NPoolsStakingSharedState.swift */; }; 0C9C643A2A8DF97E004DC078 /* StakingNPoolsError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9C64392A8DF97E004DC078 /* StakingNPoolsError.swift */; }; + 0C9D87AE2AC708070095FE8C /* AssetHubTokensConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9D87AD2AC708070095FE8C /* AssetHubTokensConverter.swift */; }; 0C9ECB5A2A4A9AB400BDCA73 /* AssetListAccountCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9ECB592A4A9AB400BDCA73 /* AssetListAccountCell.swift */; }; 0CA307BC2F570941CD22C9AA /* ExportMnemonicConfirmViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4688AF0658F8BB7A90C2BE /* ExportMnemonicConfirmViewFactory.swift */; }; + 0CA50CAF2AFE6094005668CD /* GetTokenOptionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA50CAE2AFE6094005668CD /* GetTokenOptionsViewController.swift */; }; + 0CA50CB12AFE6F15005668CD /* GetTokenOptionsProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA50CB02AFE6F15005668CD /* GetTokenOptionsProtocols.swift */; }; + 0CA50CB32AFE6F23005668CD /* GetTokenOptionsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA50CB22AFE6F23005668CD /* GetTokenOptionsPresenter.swift */; }; + 0CA50CB52AFE6F31005668CD /* GetTokenOptionsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA50CB42AFE6F31005668CD /* GetTokenOptionsInteractor.swift */; }; + 0CA50CB72AFE6F40005668CD /* GetTokenOptionsViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA50CB62AFE6F40005668CD /* GetTokenOptionsViewFactory.swift */; }; + 0CA7821C2B03D0A9003F562A /* ExtrinsicProcessor+SwapMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA7821B2B03D0A9003F562A /* ExtrinsicProcessor+SwapMatching.swift */; }; 0CAC01552A52E0CC0069413E /* AssetListModelHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAC01542A52E0CC0069413E /* AssetListModelHelpers.swift */; }; 0CAC01572A52E1960069413E /* AssetListPresenterHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAC01562A52E1960069413E /* AssetListPresenterHelpers.swift */; }; 0CAC44AA2A7A79C2001EDE61 /* StartStakingInfoParachainWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAC44A92A7A79C2001EDE61 /* StartStakingInfoParachainWireframe.swift */; }; @@ -226,6 +260,14 @@ 0CB261F52A9E188300287305 /* NominationPoolsBondExtraCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB261F42A9E188300287305 /* NominationPoolsBondExtraCall.swift */; }; 0CB261F72A9E2D8400287305 /* StackSwitchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB261F62A9E2D8400287305 /* StackSwitchCell.swift */; }; 0CB261F92A9F1F2200287305 /* NPoolsRedeemError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB261F82A9F1F2200287305 /* NPoolsRedeemError.swift */; }; + 0CB64E5A2AFE9947008F268F /* GetTokenOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB64E592AFE9947008F268F /* GetTokenOperation.swift */; }; + 0CB64E5C2B009DA9008F268F /* GetTokenOptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB64E5B2B009DA9008F268F /* GetTokenOptionsModel.swift */; }; + 0CB64E5E2B00AA8F008F268F /* GetTokenOptionsResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB64E5D2B00AA8F008F268F /* GetTokenOptionsResult.swift */; }; + 0CB64E602B00AD83008F268F /* GetTokenOptionsWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB64E5F2B00AD83008F268F /* GetTokenOptionsWireframe.swift */; }; + 0CB64E622B012E92008F268F /* TransferSetupPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB64E612B012E92008F268F /* TransferSetupPeer.swift */; }; + 0CB64E652B01E0CC008F268F /* TransferNetworkSelectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB64E642B01E0CC008F268F /* TransferNetworkSelectionCell.swift */; }; + 0CB64E672B01E174008F268F /* TransferNetworkSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB64E662B01E174008F268F /* TransferNetworkSelectionViewModel.swift */; }; + 0CB64E692B01F798008F268F /* TransferSetupOriginSelectionWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB64E682B01F798008F268F /* TransferSetupOriginSelectionWireframe.swift */; }; 0CBC29C62A421B5000F7B1F7 /* StakingMainWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC29C52A421B5000F7B1F7 /* StakingMainWireframe.swift */; }; 0CBC29C82A42AE2F00F7B1F7 /* StakingDashboardBuilderResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC29C72A42AE2F00F7B1F7 /* StakingDashboardBuilderResult.swift */; }; 0CBF5DE72AB1A60500087EBF /* SharedOperationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBF5DE62AB1A60500087EBF /* SharedOperationStatus.swift */; }; @@ -238,8 +280,20 @@ 0CC2E56A2A6E6EBB004092E7 /* LocalStorageProviderObserving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC2E5692A6E6EBB004092E7 /* LocalStorageProviderObserving.swift */; }; 0CC4CCF42A67C9C400F63041 /* Multistaking+NominationPools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC4CCF32A67C9C400F63041 /* Multistaking+NominationPools.swift */; }; 0CC6C8D82AAB401200AD8D9B /* CustomValidatorsFullList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC6C8D72AAB401200AD8D9B /* CustomValidatorsFullList.swift */; }; + 0CCA245B2AC6917400AEF23D /* XcmV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCA245A2AC6917400AEF23D /* XcmV3.swift */; }; + 0CCA245D2AC6918800AEF23D /* XcmV3Junction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCA245C2AC6918800AEF23D /* XcmV3Junction.swift */; }; + 0CCA245F2AC6974200AEF23D /* XcmV3Multilocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCA245E2AC6974200AEF23D /* XcmV3Multilocation.swift */; }; + 0CCA24652AC6B51200AEF23D /* AssetHubSwapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCA24642AC6B51200AEF23D /* AssetHubSwapTests.swift */; }; 0CCE25212A44306200286709 /* TransactionHistoryPhishingFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCE25202A44306200286709 /* TransactionHistoryPhishingFilter.swift */; }; 0CD1F4D100ED82D137AB9834 /* ParaStkStakeSetupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2F1EEBF48485F02BF690A4 /* ParaStkStakeSetupViewController.swift */; }; + 0CD352932ACAD7A500B3E446 /* AssetHubExtrinsicService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD352922ACAD7A500B3E446 /* AssetHubExtrinsicService.swift */; }; + 0CD352952ACAF59900B3E446 /* BigRational.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD352942ACAF59900B3E446 /* BigRational.swift */; }; + 0CD352972ACAFADA00B3E446 /* AssetConversionOperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD352962ACAFADA00B3E446 /* AssetConversionOperationFactory.swift */; }; + 0CD352982ACB01FD00B3E446 /* AccountGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845B822426EFE03E00D25C72 /* AccountGenerator.swift */; }; + 0CD3529B2ACD3E4300B3E446 /* PalletAssets+Call.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD3529A2ACD3E4300B3E446 /* PalletAssets+Call.swift */; }; + 0CD3A67C2AEAA3B90059BBEC /* AssetConversionFeeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD3A67B2AEAA3B90059BBEC /* AssetConversionFeeService.swift */; }; + 0CD3A67E2AEAAB670059BBEC /* AssetHubFeeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD3A67D2AEAAB670059BBEC /* AssetHubFeeService.swift */; }; + 0CD3A6802AEAC3C90059BBEC /* CompoundOperationWrapper+Add.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD3A67F2AEAC3C90059BBEC /* CompoundOperationWrapper+Add.swift */; }; 0CE150502A6FAC1200B61CC1 /* NominationPoolsPoolSubscriptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1504F2A6FAC1200B61CC1 /* NominationPoolsPoolSubscriptionService.swift */; }; 0CE150542A70EA2200B61CC1 /* NominationPoolsSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE150532A70EA2200B61CC1 /* NominationPoolsSyncTests.swift */; }; 0CE550B32A49658700F0A7AC /* StakingDuration+Localizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE550B22A49658700F0A7AC /* StakingDuration+Localizable.swift */; }; @@ -252,10 +306,20 @@ 0CE629DE2AA9B6BF00E250BD /* RewardDestinationViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE629DC2AA9B6BE00E250BD /* RewardDestinationViewModelFactory.swift */; }; 0CE629E02AA9B70200E250BD /* CalculatedReward.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE629DF2AA9B70200E250BD /* CalculatedReward.swift */; }; 0CE629E22AA9BE3000E250BD /* StakingSetupTypeEntityFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE629E12AA9BE3000E250BD /* StakingSetupTypeEntityFacade.swift */; }; + 0CEB4ED12AF14B8B0048FD84 /* SubmoduleNavigationStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEB4ED02AF14B8B0048FD84 /* SubmoduleNavigationStrategy.swift */; }; + 0CEB4ED32AF1689D0048FD84 /* AssetConversionAggregationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEB4ED22AF1689D0048FD84 /* AssetConversionAggregationFactory.swift */; }; + 0CEB4ED52AF20EB90048FD84 /* CancellableCallHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEB4ED42AF20EB90048FD84 /* CancellableCallHelper.swift */; }; + 0CEB4ED92AF371EF0048FD84 /* AssetConversionTxPayment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEB4ED82AF371EF0048FD84 /* AssetConversionTxPayment.swift */; }; 0CF193D12A843DA9003F12F6 /* StakingTypeBalanceFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF193D02A843DA9003F12F6 /* StakingTypeBalanceFactory.swift */; }; 0CF193D32A84FC7C003F12F6 /* SelectedStakingViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF193D22A84FC7C003F12F6 /* SelectedStakingViewModelFactory.swift */; }; 0CF193D52A861926003F12F6 /* PredefinedTimeShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF193D42A861925003F12F6 /* PredefinedTimeShortcut.swift */; }; 0CF193D72A861D7E003F12F6 /* StartStakingInfoConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF193D62A861D7E003F12F6 /* StartStakingInfoConstants.swift */; }; + 0CFA16132B0CD8A0007AF885 /* GovSpentAmountExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFA16122B0CD8A0007AF885 /* GovSpentAmountExtractor.swift */; }; + 0CFA16162B0CE51E007AF885 /* GovSpentAmountBatchHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFA16152B0CE51E007AF885 /* GovSpentAmountBatchHandler.swift */; }; + 0CFA16192B0CE709007AF885 /* UtilityPallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFA16182B0CE709007AF885 /* UtilityPallet.swift */; }; + 0CFA161C2B0CE851007AF885 /* Utility+Calls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFA161B2B0CE851007AF885 /* Utility+Calls.swift */; }; + 0CFA161E2B0CED07007AF885 /* GovTreasurySpentAmountHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFA161D2B0CED07007AF885 /* GovTreasurySpentAmountHandler.swift */; }; + 0CFA16202B0CEF31007AF885 /* GovTreasuryApproveHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFA161F2B0CEF31007AF885 /* GovTreasuryApproveHandler.swift */; }; 0D5245ED354CC52A842C85A0 /* TransferConfirmViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B98AB03AAF06AA891695 /* TransferConfirmViewLayout.swift */; }; 0D8213272889988B78188D9A /* DAppWalletAuthInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337EC62037D657258BCBC02F /* DAppWalletAuthInteractor.swift */; }; 0DACB56C0BDD4C984FE3C15C /* AssetReceiveWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1179A25C22AF0875A1ADCD /* AssetReceiveWireframe.swift */; }; @@ -282,6 +346,7 @@ 13CF38563E1849EAF1B4E4B6 /* ParitySignerAddConfirmViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CF76ABE7BC9A99724D393 /* ParitySignerAddConfirmViewFactory.swift */; }; 13DE59F1804CD6761EBC26B9 /* NPoolsUnstakeConfirmProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793356FB65FB73CE7097C6F1 /* NPoolsUnstakeConfirmProtocols.swift */; }; 141BF00B1B59940711773726 /* StakingSelectPoolWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6654DEA68D7ED47AE8E52206 /* StakingSelectPoolWireframe.swift */; }; + 143F6C9044429A337265DF39 /* SwapSlippageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F3BD600F80ED0426141843 /* SwapSlippageViewController.swift */; }; 148748ACAE23B7D15144015B /* DAppAuthSettingsViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F23EDFB699CAEEADC9263A0D /* DAppAuthSettingsViewFactory.swift */; }; 14DD3CD30D9D658961078037 /* ReferendumsFiltersViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025FA616E9CFA2BC7C364B74 /* ReferendumsFiltersViewFactory.swift */; }; 151722B1A7CC5B181A51869D /* StakingMoreOptionsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E4A8D1E4FEA5936AA38B24 /* StakingMoreOptionsInteractor.swift */; }; @@ -333,9 +398,11 @@ 22403E58019260719055E122 /* AdvancedWalletWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0DB5EA5195D9433A4B90793 /* AdvancedWalletWireframe.swift */; }; 2262277544A1D9CB46EF087A /* ParaStkYieldBoostStopProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD0DBD280596DBBC5CE5A8F /* ParaStkYieldBoostStopProtocols.swift */; }; 2272FB0A01000A46D097634E /* GovernanceUnlockConfirmWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E067006C1BC9DFCA5E8DB86 /* GovernanceUnlockConfirmWireframe.swift */; }; + 232B205831DF28B772515DF2 /* SwapConfirmWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CD1FBC9C063951E2520265D /* SwapConfirmWireframe.swift */; }; 233CB11F486DE1953D977295 /* WalletsListViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7073BBC153295FF46FD06FB3 /* WalletsListViewLayout.swift */; }; 2368E8BFA569B8D007F6244F /* AssetsSettingsWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFC5052A062548D20D232DA /* AssetsSettingsWireframe.swift */; }; 2450083471CD071346371995 /* MessageSheetWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14383723F0B56C91A0B3016E /* MessageSheetWireframe.swift */; }; + 2451E27286A176CDA2DC040D /* SwapSlippageWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088C765E5A0F81B96ADE72D8 /* SwapSlippageWireframe.swift */; }; 247AE9F0FFC35C35B595EEE3 /* NftDetailsWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793046EA14E4CAB096803BCD /* NftDetailsWireframe.swift */; }; 25381484F16FB930B8A90CE3 /* SelectValidatorsConfirmProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FE5F01FC9364788A91EFA5 /* SelectValidatorsConfirmProtocols.swift */; }; 255D7AEBA45EFA5324D92371 /* DAppListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED66939D92756F608FA11520 /* DAppListPresenter.swift */; }; @@ -418,6 +485,8 @@ 3441DDC002503A0DC9A8A925 /* ReferendumSearchViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B179EA3EF793684717BA9D68 /* ReferendumSearchViewFactory.swift */; }; 347BBBBCC84CA155006FDCDB /* GovernanceSelectTracksViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3593D7650F5126266ED9FE84 /* GovernanceSelectTracksViewLayout.swift */; }; 34D6FF85BEA25EFD1D15D460 /* InAppUpdatesInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15E09DD01C1CC61EA5CDED9C /* InAppUpdatesInteractor.swift */; }; + 34E4B25CDFE0D7B5F4F18185 /* TransferNetworkSelectionInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 486BF5FBE285713D05CF95A8 /* TransferNetworkSelectionInteractor.swift */; }; + 350B8A18C9C91DF07D2E53C5 /* SwapSetupViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD43373A9159E0A592077BB /* SwapSetupViewLayout.swift */; }; 355476A5AECD2FFE4ED3DE39 /* MessageSheetViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A719A9FC28373296AB195CB /* MessageSheetViewLayout.swift */; }; 3592E885646B3ED9F2717412 /* GovernanceRevokeDelegationTracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAF58F7D0659E89B66B75E4 /* GovernanceRevokeDelegationTracksViewController.swift */; }; 35F9157CAA182493B2F0E1D3 /* ParaStkRedeemInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53B5C1C920BFDA8F5C9C89D9 /* ParaStkRedeemInteractor.swift */; }; @@ -455,6 +524,7 @@ 3E480EEAF501AEB5D543506D /* UsernameSetupPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172B3E9BE51A339D7A09BDA3 /* UsernameSetupPresenter.swift */; }; 3E6215E91AE1C1F78246A43C /* ParaStkUnstakeViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611AD2A7BEEEBA634F56163D /* ParaStkUnstakeViewLayout.swift */; }; 3EAB85420EDDDE7D5B03A1CF /* GovernanceUnavailableTracksWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374AF5133A7FD39B961B9C84 /* GovernanceUnavailableTracksWireframe.swift */; }; + 3EE29545824B68594751769C /* SwapSetupPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB494F0B16C9588325CF0D84 /* SwapSetupPresenter.swift */; }; 3F3AE7490C59A0CE0BF2D7A7 /* DAppWalletAuthViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4A5B4E6779AA27F10713C6 /* DAppWalletAuthViewFactory.swift */; }; 3F7F10D0E1BDE09CBE64BD2D /* CrowdloanYourContributionsViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC155A29FA8777D90A46913D /* CrowdloanYourContributionsViewFactory.swift */; }; 3FC436AED4098456EDEAF484 /* MessageSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A2F34329579E12A2836E77 /* MessageSheetViewController.swift */; }; @@ -530,6 +600,7 @@ 542588DA751A44C993BC1F27 /* ParaStkYourCollatorsWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8C13B77688FFF0FFBBB6612 /* ParaStkYourCollatorsWireframe.swift */; }; 5443122935BBFDD55AE9E6FD /* ParitySignerAddressesProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F4F3C080F3D5C1E64475903 /* ParitySignerAddressesProtocols.swift */; }; 544C8EB3D71227FAF2FD4658 /* GovernanceRemoveVotesConfirmPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29FE1BF422468BECDCDEE63 /* GovernanceRemoveVotesConfirmPresenter.swift */; }; + 54813408A51B4AEBA3EED0A5 /* SwapSetupInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C585109AC3A2580AB1253C31 /* SwapSetupInteractor.swift */; }; 54983C354F7EDCD8014C8371 /* WalletConnectSessionDetailsViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE103341935B2A4B8C32B966 /* WalletConnectSessionDetailsViewFactory.swift */; }; 54D334605E9A7C71A4873CFC /* ParaStkRedeemWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578743E9101B334BFBE44CB6 /* ParaStkRedeemWireframe.swift */; }; 5510625BDA756B939ED7C586 /* AddDelegationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68D44AF5C59681B54ECD7658 /* AddDelegationPresenter.swift */; }; @@ -553,6 +624,7 @@ 59A0AF440ABAAA459EF7D993 /* GovernanceYourDelegationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4462DCD832DB73AA78D44C /* GovernanceYourDelegationsViewController.swift */; }; 59D03D8CB4AE60DE53130729 /* NPoolsRedeemViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EF6FA61F2B7E3B2ADD3200 /* NPoolsRedeemViewLayout.swift */; }; 5AC2A8AD94278DFA4B68A718 /* NominationPoolSearchInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44AA632DE49B746BC38B959F /* NominationPoolSearchInteractor.swift */; }; + 5B02D050E6E067906A05B46B /* SwapSetupWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F910E56C2CC7AA5224BD21 /* SwapSetupWireframe.swift */; }; 5B54978244C37502DD592486 /* NftListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A092ADC09DA0429548EBC08 /* NftListPresenter.swift */; }; 5B652F1E0040F68F835A2F1D /* AssetDetailsViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE05ECCFE3DD11A2EAAF495 /* AssetDetailsViewLayout.swift */; }; 5C796EF8ED29F564B5D1126B /* CrowdloanContributionConfirmViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F75722D2F921FD1C2D4105D /* CrowdloanContributionConfirmViewController.swift */; }; @@ -593,6 +665,8 @@ 65C06FCE82EEC0B476DB1CEF /* DAppBrowserProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E38ABE379CA48E63328C4 /* DAppBrowserProtocols.swift */; }; 65CD159259A06EC3E92FD4B0 /* AssetDetailsProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 998CDEAB9F149770B27F5317 /* AssetDetailsProtocols.swift */; }; 663DB041307C59E939BF0BE2 /* ParitySignerAddConfirmInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44809BCF44D7329266A60A9D /* ParitySignerAddConfirmInteractor.swift */; }; + 66436210FD782DAF13814711 /* TransferNetworkSelectionPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE1DE626A1960428C9AEC52 /* TransferNetworkSelectionPresenter.swift */; }; + 66531C7E2E0E99C89A89A35A /* SwapSetupViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A6BCE8F8DB7576E1E5B5974 /* SwapSetupViewFactory.swift */; }; 671C5788468FE8445A46C09F /* AdvancedWalletViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 597A3C3F2937333D0EC7ABD5 /* AdvancedWalletViewController.swift */; }; 67684F7576ED0252C1050CA5 /* OperationDetailsViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D569738955713647612599 /* OperationDetailsViewLayout.swift */; }; 676B1511C4A34528C668751D /* GovernanceRevokeDelegationConfirmWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B263D5668F1C91E2CF61D9 /* GovernanceRevokeDelegationConfirmWireframe.swift */; }; @@ -657,6 +731,19 @@ 770F578B2A8A48FF005FD7C1 /* ButtonViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 770F578A2A8A48FF005FD7C1 /* ButtonViewModel.swift */; }; 77171CA82A98BBA10032B387 /* NominationPoolErrorPresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77171CA72A98BBA10032B387 /* NominationPoolErrorPresentable.swift */; }; 77171CAA2A98BC420032B387 /* NominationPoolDataValidatorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77171CA92A98BC420032B387 /* NominationPoolDataValidatorFactory.swift */; }; + 7719018C2AE0E70B00D9C918 /* SwapDataValidatorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7719018B2AE0E70B00D9C918 /* SwapDataValidatorFactory.swift */; }; + 7719018E2AE0E71F00D9C918 /* SwapErrorPresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7719018D2AE0E71F00D9C918 /* SwapErrorPresentable.swift */; }; + 771901902AE2424B00D9C918 /* SwapsValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7719018F2AE2424B00D9C918 /* SwapsValidationTests.swift */; }; + 7719019B2AE670AE00D9C918 /* ShortTextInfoPresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7719019A2AE670AE00D9C918 /* ShortTextInfoPresentable.swift */; }; + 7719019D2AE6996600D9C918 /* SwapPairView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7719019C2AE6996600D9C918 /* SwapPairView.swift */; }; + 7719019F2AE6C9DC00D9C918 /* SwapElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7719019E2AE6C9DC00D9C918 /* SwapElementView.swift */; }; + 771901A22AE7E34D00D9C918 /* SwapBaseInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771901A12AE7E34D00D9C918 /* SwapBaseInteractor.swift */; }; + 771901A42AE7E48800D9C918 /* SwapBaseProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771901A32AE7E48800D9C918 /* SwapBaseProtocols.swift */; }; + 771901A62AE8FF7E00D9C918 /* SwapInfoViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771901A52AE8FF7E00D9C918 /* SwapInfoViewCell.swift */; }; + 771901A82AE8FF9F00D9C918 /* SwapNetworkFeeViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771901A72AE8FF9F00D9C918 /* SwapNetworkFeeViewCell.swift */; }; + 771901AB2AE9581400D9C918 /* SwapConfirmViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771901AA2AE9581400D9C918 /* SwapConfirmViewModelFactory.swift */; }; + 771901B02AE97DA500D9C918 /* SwapConfirmViewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771901AF2AE97DA500D9C918 /* SwapConfirmViewModels.swift */; }; + 771901B22AEA3E5100D9C918 /* ShortTextInfoPresentableExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771901B12AEA3E5100D9C918 /* ShortTextInfoPresentableExtensions.swift */; }; 77204EA42A1CD59100BBDE4A /* GenericBorderedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77204EA32A1CD59100BBDE4A /* GenericBorderedView.swift */; }; 77204EA62A1E0EAA00BBDE4A /* WalletConnectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77204EA52A1E0EAA00BBDE4A /* WalletConnectionsView.swift */; }; 7725062C2A1C99DB00E653DB /* ReferendumSearchViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7725062B2A1C99DB00E653DB /* ReferendumSearchViewLayout.swift */; }; @@ -673,11 +760,21 @@ 772B1C7D2A93837800A19061 /* StartStakingCustomValidatorListWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 772B1C7C2A93837800A19061 /* StartStakingCustomValidatorListWireframe.swift */; }; 7731E9C42A14DA3F0085B5FF /* BorderedActionControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7731E9C32A14DA3F0085B5FF /* BorderedActionControlView.swift */; }; 7738FB6A2A4C5D1A00797439 /* StartStakingRelaychainInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738FB692A4C5D1A00797439 /* StartStakingRelaychainInteractor.swift */; }; + 774091F92ACB1F4B00172516 /* SwapAmountInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 774091F82ACB1F4B00172516 /* SwapAmountInputView.swift */; }; + 774091FC2ACC053000172516 /* SwapAssetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 774091FB2ACC053000172516 /* SwapAssetView.swift */; }; + 774091FE2ACC054B00172516 /* SwapAssetControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 774091FD2ACC054B00172516 /* SwapAssetControl.swift */; }; + 774092002ACC1BE400172516 /* SwapAmountInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 774091FF2ACC1BE400172516 /* SwapAmountInput.swift */; }; 774A481129F8BFB70094635B /* OperationAuthPresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 774A481029F8BFB70094635B /* OperationAuthPresentable.swift */; }; 7756927D2A20B88200220756 /* TokenOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7756927C2A20B88200220756 /* TokenOperation.swift */; }; 775F194D2A56EEAC009915B6 /* StartStakingInfoRelaychainPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775F194C2A56EEAC009915B6 /* StartStakingInfoRelaychainPresenter.swift */; }; 775F19512A5811FA009915B6 /* StartStakingParachainInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775F19502A5811FA009915B6 /* StartStakingParachainInteractor.swift */; }; 775F19532A5BDFB6009915B6 /* StartStakingInfoParachainPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775F19522A5BDFB6009915B6 /* StartStakingInfoParachainPresenter.swift */; }; + 77740BBC2AD4A7B800E8C06F /* CollapsableContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77740BBB2AD4A7B800E8C06F /* CollapsableContainerView.swift */; }; + 77740BBE2AD4A7F500E8C06F /* SwapDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77740BBD2AD4A7F500E8C06F /* SwapDetailsView.swift */; }; + 77740BC02AD4A80D00E8C06F /* SwapInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77740BBF2AD4A80D00E8C06F /* SwapInfoView.swift */; }; + 77740BC22AD69E3400E8C06F /* SwapMaxButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77740BC12AD69E3400E8C06F /* SwapMaxButtonView.swift */; }; + 77740BC42AD8145500E8C06F /* PercentInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77740BC32AD8145500E8C06F /* PercentInputView.swift */; }; + 77740BC62AD849D100E8C06F /* SlippagePercentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77740BC52AD849D100E8C06F /* SlippagePercentViewModel.swift */; }; 77799ADD2A74219A00B7E564 /* ButtonState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77799ADC2A74219A00B7E564 /* ButtonState.swift */; }; 77799ADF2A78DAB200B7E564 /* StartStakingInfoRelaychainWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77799ADE2A78DAB200B7E564 /* StartStakingInfoRelaychainWireframe.swift */; }; 77799AE52A792AE700B7E564 /* StakingTypeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77799AE42A792AE700B7E564 /* StakingTypeViewModel.swift */; }; @@ -724,6 +821,8 @@ 77A0B2F32A3CA37E00CBF653 /* StakingMoreOptionsViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A0B2F22A3CA37E00CBF653 /* StakingMoreOptionsViewModelFactory.swift */; }; 77A0B2F52A3CA39D00CBF653 /* StakingMoreOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A0B2F42A3CA39D00CBF653 /* StakingMoreOptionCollectionViewCell.swift */; }; 77A0B2F92A3CA40E00CBF653 /* StakingMoreOptionsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A0B2F82A3CA40E00CBF653 /* StakingMoreOptionsSection.swift */; }; + 77A4F4012B035027006294BC /* AssetOperationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A4F4002B035027006294BC /* AssetOperationState.swift */; }; + 77A4F4032B036615006294BC /* Optional+Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A4F4022B036615006294BC /* Optional+Result.swift */; }; 77A6F5AB2A2E0B31004AFD1A /* ReceiveAssetOperationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A6F5AA2A2E0B31004AFD1A /* ReceiveAssetOperationPresenter.swift */; }; 77A6F5AE2A2E0C7C004AFD1A /* ReceiveAssetOperationWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A6F5AD2A2E0C7C004AFD1A /* ReceiveAssetOperationWireframe.swift */; }; 77A6F5B92A2E2AAD004AFD1A /* BuyAssetOperationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A6F5B72A2E2AAD004AFD1A /* BuyAssetOperationPresenter.swift */; }; @@ -737,9 +836,28 @@ 77A6F5CF2A31C4D4004AFD1A /* Web3TransferRecipientRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A6F5CE2A31C4D4004AFD1A /* Web3TransferRecipientRepositoryFactory.swift */; }; 77A6F5D22A31DB8C004AFD1A /* JsonCanonicalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A6F5D12A31DB8C004AFD1A /* JsonCanonicalizer.swift */; }; 77A6F5D52A31E046004AFD1A /* JsonCanonicalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A6F5D42A31E046004AFD1A /* JsonCanonicalizerTests.swift */; }; + 77AAE2202AFB00CB006872CC /* OperationSwapModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AAE21F2AFB00CB006872CC /* OperationSwapModel.swift */; }; + 77AAE2222AFB026E006872CC /* OperationDetailsSwapProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AAE2212AFB026E006872CC /* OperationDetailsSwapProvider.swift */; }; + 77AAE2242AFB67BE006872CC /* OperationSwapDetailsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AAE2232AFB67BE006872CC /* OperationSwapDetailsInteractor.swift */; }; + 77AAE2262AFC10EE006872CC /* PriceHistoryCalculatorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AAE2252AFC10EE006872CC /* PriceHistoryCalculatorFactory.swift */; }; + 77AAE2282AFC1167006872CC /* ChainModel+historyId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AAE2272AFC1167006872CC /* ChainModel+historyId.swift */; }; + 77AAE22A2AFC36EE006872CC /* OperationDetailsBaseInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AAE2292AFC36EE006872CC /* OperationDetailsBaseInteractor.swift */; }; 77AB55592AA244BB0058814E /* OperationPoolRewardOrSlashModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AB55582AA244BB0058814E /* OperationPoolRewardOrSlashModel.swift */; }; 77AB555B2AA246CA0058814E /* OperationPoolRewardOrSlashViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AB555A2AA246CA0058814E /* OperationPoolRewardOrSlashViewModel.swift */; }; 77AB555D2AA24BA90058814E /* OperationDetailsPoolRewardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AB555C2AA24BA90058814E /* OperationDetailsPoolRewardView.swift */; }; + 77C976202AF36A170049272C /* SwapModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C9761F2AF36A170049272C /* SwapModels.swift */; }; + 77C976222AF39F180049272C /* TokenOperationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C976212AF39F180049272C /* TokenOperationTableViewCell.swift */; }; + 77C976242AF3A5280049272C /* SwapViewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C976232AF3A5280049272C /* SwapViewModels.swift */; }; + 77C976262AF421AE0049272C /* OperationDetailsSwapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C976252AF421AE0049272C /* OperationDetailsSwapView.swift */; }; + 77C976282AF426100049272C /* OperationSwapViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C976272AF426100049272C /* OperationSwapViewModel.swift */; }; + 77C9BCBE2ACD286100022EA2 /* SwapsSetupViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C9BCBD2ACD286100022EA2 /* SwapsSetupViewModelFactory.swift */; }; + 77C9BCC42ACD570100022EA2 /* SwapAssetsOperationInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C9BCC32ACD570100022EA2 /* SwapAssetsOperationInteractor.swift */; }; + 77C9BCC62ACD571400022EA2 /* SwapAssetOperationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C9BCC52ACD571400022EA2 /* SwapAssetOperationPresenter.swift */; }; + 77C9BCCA2ACD574800022EA2 /* SwapAssetOperationWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C9BCC92ACD574800022EA2 /* SwapAssetOperationWireframe.swift */; }; + 77C9BCCC2ACD576B00022EA2 /* SwapAssetsOperationProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C9BCCB2ACD576B00022EA2 /* SwapAssetsOperationProtocols.swift */; }; + 77C9BCCE2ACD691300022EA2 /* SwapAssetsOperationViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C9BCCD2ACD691300022EA2 /* SwapAssetsOperationViewFactory.swift */; }; + 77C9BCD02ACD8A3600022EA2 /* SwapAssetsOperationViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C9BCCF2ACD8A3600022EA2 /* SwapAssetsOperationViewLayout.swift */; }; + 77C9BCD22ACD8AF400022EA2 /* SwapAssetsOperationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C9BCD12ACD8AF400022EA2 /* SwapAssetsOperationViewController.swift */; }; 77CB33CE2A38780700B6709A /* structures_output.json in Resources */ = {isa = PBXBuildFile; fileRef = 77CB33CD2A38780700B6709A /* structures_output.json */; }; 77CB33D22A38893900B6709A /* Web3NameIntegrityVerifierWithCanonicalizationData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77CB33D12A38893900B6709A /* Web3NameIntegrityVerifierWithCanonicalizationData.swift */; }; 77CB33D72A3998FD00B6709A /* Array+Sort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77CB33D62A3998FC00B6709A /* Array+Sort.swift */; }; @@ -749,9 +867,18 @@ 77CC82A32A984BC3002D022F /* StartStakingSelectedValidatorsListWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77CC82A22A984BC3002D022F /* StartStakingSelectedValidatorsListWireframe.swift */; }; 77CC82A52A984EDA002D022F /* UINavigaionController+Pop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77CC82A42A984EDA002D022F /* UINavigaionController+Pop.swift */; }; 77CC82A72A986CF1002D022F /* StakingSelectValidatorsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77CC82A62A986CF1002D022F /* StakingSelectValidatorsDelegate.swift */; }; + 77D2E2712B05416E0098F188 /* AssetListGroupModelCompator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D2E2702B05416E0098F188 /* AssetListGroupModelCompator.swift */; }; + 77D2E2732B0542A50098F188 /* AmountPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D2E2722B0542A50098F188 /* AmountPair.swift */; }; 77E0DC9E2A6940C400D03724 /* Calendar+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E0DC9D2A6940C400D03724 /* Calendar+Helpers.swift */; }; 77E255672A16145500B644C3 /* StakingRewardsFilterMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E255662A16145500B644C3 /* StakingRewardsFilterMapper.swift */; }; 77E255692A16148000B644C3 /* StakingRewardsFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E255682A16148000B644C3 /* StakingRewardsFilter.swift */; }; + 77E304A92AEB9F76006FD6F0 /* SwapConfirmInitState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E304A82AEB9F76006FD6F0 /* SwapConfirmInitState.swift */; }; + 77E304AB2AEBB214006FD6F0 /* SlippageBounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E304AA2AEBB214006FD6F0 /* SlippageBounds.swift */; }; + 77E304AD2AEC16B2006FD6F0 /* SwapPriceDifferenceViewModelFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E304AC2AEC16B2006FD6F0 /* SwapPriceDifferenceViewModelFactoryProtocol.swift */; }; + 77E304B02AEFA261006FD6F0 /* SwapNetworkFeeSheetLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E304AF2AEFA261006FD6F0 /* SwapNetworkFeeSheetLayout.swift */; }; + 77E304B22AEFC0F3006FD6F0 /* SwapNetworkFeeSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E304B12AEFC0F3006FD6F0 /* SwapNetworkFeeSheetViewController.swift */; }; + 77E304B52AEFC303006FD6F0 /* SwapNetworkFeeSheetViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E304B42AEFC303006FD6F0 /* SwapNetworkFeeSheetViewFactory.swift */; }; + 77E304B72AEFC348006FD6F0 /* SwapNetworkFeeSheetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E304B62AEFC348006FD6F0 /* SwapNetworkFeeSheetViewModel.swift */; }; 77EA2A232A333C1500B0670B /* french_output.json in Resources */ = {isa = PBXBuildFile; fileRef = 77EA2A182A333C1500B0670B /* french_output.json */; }; 77EA2A242A333C1500B0670B /* arrays_output.json in Resources */ = {isa = PBXBuildFile; fileRef = 77EA2A192A333C1500B0670B /* arrays_output.json */; }; 77EA2A252A333C1500B0670B /* weird_output.json in Resources */ = {isa = PBXBuildFile; fileRef = 77EA2A1A2A333C1500B0670B /* weird_output.json */; }; @@ -759,6 +886,7 @@ 77EA2A292A333C1500B0670B /* arrays_input.json in Resources */ = {isa = PBXBuildFile; fileRef = 77EA2A1F2A333C1500B0670B /* arrays_input.json */; }; 77EA2A2A2A333C1500B0670B /* weird_input.json in Resources */ = {isa = PBXBuildFile; fileRef = 77EA2A202A333C1500B0670B /* weird_input.json */; }; 77EA2A2B2A333C1500B0670B /* structures_input.json in Resources */ = {isa = PBXBuildFile; fileRef = 77EA2A212A333C1500B0670B /* structures_input.json */; }; + 77ECB4702ACEEE2E0015CE9F /* SwapNetworkFeeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77ECB46F2ACEEE2D0015CE9F /* SwapNetworkFeeView.swift */; }; 77ED167A2A0CF41700E1FC8C /* StakingRewardFiltersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77ED16792A0CF41600E1FC8C /* StakingRewardFiltersViewModel.swift */; }; 77ED167C2A0CF42E00E1FC8C /* StakingRewardFiltersPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77ED167B2A0CF42E00E1FC8C /* StakingRewardFiltersPeriod.swift */; }; 77ED167E2A0D0AE900E1FC8C /* Lenses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77ED167D2A0D0AE900E1FC8C /* Lenses.swift */; }; @@ -792,6 +920,7 @@ 78D94A761EFECED60F38232D /* CustomValidatorListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 270B309EC85D8897A4ADD98A /* CustomValidatorListViewController.swift */; }; 78E0B6963A8D0A07E742232C /* GovernanceEditDelegationTracksWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC3B84FA1F22CC12B16C79AE /* GovernanceEditDelegationTracksWireframe.swift */; }; 790129F3CB6AEA611639E886 /* ParaStkUnstakeViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5467B9B6AEDB33F565D130A1 /* ParaStkUnstakeViewFactory.swift */; }; + 7A47B0731BC8B411CC01C3D1 /* TransferNetworkSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6106D11494F979A46F7B4C /* TransferNetworkSelectionViewController.swift */; }; 7BD09D3022967C4D90AB4693 /* DAppOperationConfirmViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859E0EF774DF0D498FEF8FCB /* DAppOperationConfirmViewLayout.swift */; }; 7C0135CA49EF6B535030643E /* ParaStkYieldBoostSetupPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C9EEC5FDEA7DAE42F2880C7 /* ParaStkYieldBoostSetupPresenter.swift */; }; 7C4CB158ED48716626780F40 /* LedgerPerformOperationInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4809C2DB0C15A9B2890C1AC6 /* LedgerPerformOperationInteractor.swift */; }; @@ -815,6 +944,7 @@ 800FCAF66DC8A24020D16A9C /* AccountExportPasswordInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194C9BFEE9BA8C9E448D79AA /* AccountExportPasswordInteractor.swift */; }; 80175BD9EE66BCE4016E7F28 /* GovernanceDelegateConfirmViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BA3C305F49A4E609C7A5C14 /* GovernanceDelegateConfirmViewController.swift */; }; 8027EA456C0C13F6DA73D540 /* MoonbeamTermsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837E9D1F8096A0E9CA0E0CEB /* MoonbeamTermsPresenter.swift */; }; + 80603DA36CD481AE310CDFE1 /* SwapSlippageViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28294C13CF8F62D2FE4D0427 /* SwapSlippageViewFactory.swift */; }; 811096BAAA6BD237DF2769EA /* ReferendumVoteSetupViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF755EE09598254BB5E59CC2 /* ReferendumVoteSetupViewFactory.swift */; }; 81544BD01F6AD0197588D3C5 /* OperationDetailsWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC6917929E4A752B79FE554 /* OperationDetailsWireframe.swift */; }; 81ADC94E1CC47A2C6F0F1BEA /* GovernanceEditDelegationTracksProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32F1F0F6F985195CD19EDDB /* GovernanceEditDelegationTracksProtocols.swift */; }; @@ -1144,7 +1274,7 @@ 842AEB81292F34B600C61B0C /* RemoteChainExternalApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842AEB80292F34B600C61B0C /* RemoteChainExternalApi.swift */; }; 842B17FB28648FDC0014CC57 /* ChainAssetViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842B17FA28648FDC0014CC57 /* ChainAssetViewModelFactory.swift */; }; 842B17FD2864980B0014CC57 /* NetworkSelectionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842B17FC2864980B0014CC57 /* NetworkSelectionTableViewCell.swift */; }; - 842B17FF28649CCD0014CC57 /* CrossChainDestinationSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842B17FE28649CCD0014CC57 /* CrossChainDestinationSelectionState.swift */; }; + 842B17FF28649CCD0014CC57 /* CrossChainSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842B17FE28649CCD0014CC57 /* CrossChainSelectionState.swift */; }; 842B18022864F9950014CC57 /* CrossChainTransferPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842B18012864F9950014CC57 /* CrossChainTransferPresenter.swift */; }; 842B18042864F9A90014CC57 /* CrossChainTransferInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842B18032864F9A90014CC57 /* CrossChainTransferInteractor.swift */; }; 842B1806286506EE0014CC57 /* CrossChainTransferSetupProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842B1805286506EE0014CC57 /* CrossChainTransferSetupProtocols.swift */; }; @@ -2109,7 +2239,6 @@ 84948C36287DD1C800E6DD3E /* NftListRMRKV2ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84948C35287DD1C800E6DD3E /* NftListRMRKV2ViewModel.swift */; }; 84948C38287E0B4F00E6DD3E /* FilterImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84948C37287E0B4F00E6DD3E /* FilterImageProcessor.swift */; }; 8494D86B25247F9600614D8F /* Decimal+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8494D86A25247F9600614D8F /* Decimal+String.swift */; }; - 849528E326036997009DC845 /* RewardEstimationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849528E226036997009DC845 /* RewardEstimationView.swift */; }; 84953F662934C7D90033F47D /* EtherscanERC20HistoryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84953F652934C7D90033F47D /* EtherscanERC20HistoryResponse.swift */; }; 84953F682934C8A70033F47D /* EtherscanERC20HistoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84953F672934C8A70033F47D /* EtherscanERC20HistoryInfo.swift */; }; 84953F6A2934C9E20033F47D /* EtherscanERC20OperationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84953F692934C9E20033F47D /* EtherscanERC20OperationFactory.swift */; }; @@ -2980,7 +3109,6 @@ 84FB9E1C285C57EF00B42FC0 /* XcmMultilocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FB9E1B285C57EF00B42FC0 /* XcmMultilocation.swift */; }; 84FB9E1E285C58FF00B42FC0 /* Xcm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FB9E1D285C58FF00B42FC0 /* Xcm.swift */; }; 84FB9E20285C5C9E00B42FC0 /* XcmJunction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FB9E1F285C5C9E00B42FC0 /* XcmJunction.swift */; }; - 84FB9E22285C5D6200B42FC0 /* XcmNetworkId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FB9E21285C5D6200B42FC0 /* XcmNetworkId.swift */; }; 84FB9E24285C6ACC00B42FC0 /* XcmMultiasset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FB9E23285C6ACC00B42FC0 /* XcmMultiasset.swift */; }; 84FB9E26285C6C5000B42FC0 /* XcmVersionedMultiasset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FB9E25285C6C5000B42FC0 /* XcmVersionedMultiasset.swift */; }; 84FB9E28285C736A00B42FC0 /* XcmTransferFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FB9E27285C736A00B42FC0 /* XcmTransferFactory.swift */; }; @@ -3031,6 +3159,7 @@ 85A093F6055DDD2E2E9253F2 /* ControllerAccountProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = F829E7F8B39EE7D977001510 /* ControllerAccountProtocols.swift */; }; 86EB789787B731691B36C827 /* OnChainTransferSetupPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1A2F7E5E278FDCC89FE097 /* OnChainTransferSetupPresenter.swift */; }; 873FAB6E5CAD1FD4D02737D0 /* NominationPoolBondMoreSetupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C9473AB7D1FE4A27403078 /* NominationPoolBondMoreSetupViewController.swift */; }; + 8786222ADF4643BE7A6FBBEB /* SwapSetupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEB2BC52266AF493D310834 /* SwapSetupViewController.swift */; }; 879D493C025963619CFADF4F /* GovernanceUnlockSetupProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4642DD5186EFA940518CCB4 /* GovernanceUnlockSetupProtocols.swift */; }; 87F7556E02F6F5BB6F1B1AEA /* ParitySignerTxQrViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A5DCA28ABF42D342BBDF9A /* ParitySignerTxQrViewLayout.swift */; }; 880059D828EEBC0200E87B9B /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880059D728EEBC0200E87B9B /* SliderView.swift */; }; @@ -3315,6 +3444,7 @@ 88FF5C7F29C8364500D1CB5D /* Caip2+ParseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88FF5C7E29C8364500D1CB5D /* Caip2+ParseError.swift */; }; 8916E9179CF5409E65D1B3A6 /* NftDetailsProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A46EE888D60C1538A0A3EFC /* NftDetailsProtocols.swift */; }; 893CA0DF4EAD03CC118A2733 /* GovernanceRemoveVotesConfirmViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75247AFBEFD8347011098F69 /* GovernanceRemoveVotesConfirmViewFactory.swift */; }; + 8960EC452EA6E7E9105F4762 /* SwapConfirmInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DEED7526468089FE8A8989C /* SwapConfirmInteractor.swift */; }; 89724EA9F732D0C967253597 /* ReferendumOnChainVotersViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF3C1CFECE4340E82837FC4 /* ReferendumOnChainVotersViewFactory.swift */; }; 8A19EC93E6A6972327116D80 /* ParaStkStakeConfirmProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1139FB38E7D8D25A36726089 /* ParaStkStakeConfirmProtocols.swift */; }; 8A23DD1F4146639EA2F7AEF6 /* LocksViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81B239BD9C150BFE9A82B0 /* LocksViewFactory.swift */; }; @@ -3342,6 +3472,7 @@ 91A1286763617DE022BD495F /* LedgerInstructionsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B08ACC71BE679A48A7B66E /* LedgerInstructionsPresenter.swift */; }; 921E4891E85C0DC6FDD8A0D0 /* CrowdloanContributionConfirmInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1366336078BCA34EFB4C6FF9 /* CrowdloanContributionConfirmInteractor.swift */; }; 924BADB89E7FA2DC54BF1A02 /* NPoolsClaimRewardsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F1F933F624B01855AA3BA5 /* NPoolsClaimRewardsInteractor.swift */; }; + 92984DFE797C52644C084377 /* SwapSetupProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53775773F2060B4B7F6D62DA /* SwapSetupProtocols.swift */; }; 93434E8E407A6C63D8862A21 /* AssetSelectionProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DECC58C93DB18E79A03B5A0 /* AssetSelectionProtocols.swift */; }; 934F229F4E5A588D5AF2A093 /* TokensAddSelectNetworkViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E800814C025B38C87CC282D /* TokensAddSelectNetworkViewFactory.swift */; }; 9358E048B1AA0F71F519101E /* GovernanceDelegateConfirmInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27FB20961F2F221A96624A6 /* GovernanceDelegateConfirmInteractor.swift */; }; @@ -3349,6 +3480,7 @@ 93EB8C73108944E9C576936C /* ReferendumVotersPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9990DF2F0214CD51E5388CE /* ReferendumVotersPresenter.swift */; }; 940DA38E4586A27D7F3E0C67 /* ParitySignerAddressesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3280014154DCC105757E317C /* ParitySignerAddressesViewController.swift */; }; 948FE60822DFC49A0BD5740B /* NominationPoolBondMoreBaseWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4009AE7EF95E9CE1EB88B8 /* NominationPoolBondMoreBaseWireframe.swift */; }; + 94A6747402550FB39D2E2BE7 /* SwapSlippageProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE0E677E64DAC7E93562412 /* SwapSlippageProtocols.swift */; }; 94B0F0C84AF74B3CD7223C3A /* AccountConfirmPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7306D50F278F6CC90DC88F27 /* AccountConfirmPresenter.swift */; }; 94B234EE404088B077DB6411 /* DAppListProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F3CCA5BA57C68D5AE2B42F /* DAppListProtocols.swift */; }; 9565BEB636E6D386B0C0FBE5 /* StakingPayoutConfirmationViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE0492B98AB9C1540846B39 /* StakingPayoutConfirmationViewFactory.swift */; }; @@ -3394,6 +3526,7 @@ A07A987DE3047AF1A786D511 /* DAppListViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BB49F165F64A7EF6418EB4 /* DAppListViewLayout.swift */; }; A090FF206B56A0E465C62072 /* CrowdloanListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F7A369E31DCB9ABD556EE9 /* CrowdloanListPresenter.swift */; }; A0EFC9C4C6F0AE9AFDA9A3EA /* NominationPoolSearchPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1090AF9D9F18846E5A73F73C /* NominationPoolSearchPresenter.swift */; }; + A0FB39F2C6192F57B2FAFC37 /* SwapConfirmPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E6BE303472080271AAC917 /* SwapConfirmPresenter.swift */; }; A14308E2633921838166C843 /* ParaStkUnstakeConfirmViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F2450A063F4D66EFDF6B8A /* ParaStkUnstakeConfirmViewFactory.swift */; }; A1FA23B8A833B6896104ABA6 /* GovernanceSelectTracksWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE82B42E77A241D336A5B65F /* GovernanceSelectTracksWireframe.swift */; }; A265CC9857E951EB71E5E831 /* WalletsListInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DBACA1AB17E90565F133C19 /* WalletsListInteractor.swift */; }; @@ -3612,6 +3745,7 @@ C20ED4531583D0C8E38715E0 /* PurchaseProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 782CC21A2F9EEF5DBA3AB1AA /* PurchaseProtocols.swift */; }; C21129B2B8D8B33BCBD5843E /* StakingRedeemViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8821119C96944A0E3526E93A /* StakingRedeemViewFactory.swift */; }; C24EC1F763113B2208BE1ABE /* AssetsSettingsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7280DD0C282CB1631C93DFB /* AssetsSettingsPresenter.swift */; }; + C28A0BFC483095E132705C83 /* SwapConfirmViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382B8763E4FDA7C4E2EF06A8 /* SwapConfirmViewLayout.swift */; }; C29BA2F7EBAFED5D118E0C0C /* GovernanceRevokeDelegationConfirmViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F98F75F1BCA9143356DBF08 /* GovernanceRevokeDelegationConfirmViewFactory.swift */; }; C2DD387223F4520ADC49AE9B /* NftDetailsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6B5B187E83839481846C7E /* NftDetailsInteractor.swift */; }; C317BBF2F1815F0A8D937428 /* DelegateVotedReferendaViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E86B1DF9D9E8E5DD8DC49DE /* DelegateVotedReferendaViewLayout.swift */; }; @@ -3622,6 +3756,7 @@ C5B07E59C0B00CAD1D0D2DFD /* ReferendumSearchWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EF3BEC3EA07548C10A87FD /* ReferendumSearchWireframe.swift */; }; C5B6C00F8B0E3D89CBF1A8DB /* ParaStkSelectCollatorsViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2336E4CAF4A1F627C39093FF /* ParaStkSelectCollatorsViewFactory.swift */; }; C5B833588833888A8AC2B8EE /* TransactionHistoryInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8511A3B5C31B73CDC794F63 /* TransactionHistoryInteractor.swift */; }; + C61286217D7D54EC70E3F1AA /* SwapConfirmViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF5DEDDC53639DFCF524D794 /* SwapConfirmViewFactory.swift */; }; C644308270C29AC6F90CFEA6 /* ReferendumDetailsWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2EB9EE4A87BD4A74040784 /* ReferendumDetailsWireframe.swift */; }; C6A2D4CAEE2E6444507EEDDF /* TokensManageAddWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182E3BCA71937085FBCBDD1D /* TokensManageAddWireframe.swift */; }; C6DA3ABD47E72ED1661830A9 /* GovernanceDelegateSearchPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3D49E5A3BB8ABFA1448BF43 /* GovernanceDelegateSearchPresenter.swift */; }; @@ -3758,10 +3893,12 @@ E9625CE429290F5504728D62 /* NftListWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0AFEA616FDDF846F3F3650 /* NftListWireframe.swift */; }; E9B2CD5127B700881A00EC1D /* TokensAddSelectNetworkInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA518E1D79D86360F145B428 /* TokensAddSelectNetworkInteractor.swift */; }; EA44FEB35901A8BD8B1A4B79 /* StakingDashboardWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3348E0824B8D42EC3850EBD5 /* StakingDashboardWireframe.swift */; }; + EA7BA01C6745ABE0E1AF7E1D /* SwapConfirmViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2FB715B933FB8E34A553A80 /* SwapConfirmViewController.swift */; }; EAAB9E53189BC6394C5900D2 /* GovernanceSelectTracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6962C8E51EB317DE3AAE4BDF /* GovernanceSelectTracksViewController.swift */; }; EAAFB082E2BB0CA418714061 /* ReferendumFullDetailsViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57CDCB8CF3D8EBE5DA7A5A30 /* ReferendumFullDetailsViewLayout.swift */; }; EB11BF594D7E16A8885D47DD /* WalletConnectServiceFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B193A261FDF933FE6C874B4E /* WalletConnectServiceFactory.swift */; }; EB20C6B406155664B981BA94 /* GovernanceYourDelegationsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46DF4E6D8DAF6913474DED5 /* GovernanceYourDelegationsInteractor.swift */; }; + EB280B977471EA442D91395E /* SwapConfirmProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796859464B823B60746C5DE5 /* SwapConfirmProtocols.swift */; }; EB376E61CD1C39AC148DE80C /* NftListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95A60B27D3A045E0DEF23775 /* NftListViewController.swift */; }; EB5F587A71CCE1F0F86154CF /* ControllerAccountViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002A29AE58EB53E915330490 /* ControllerAccountViewFactory.swift */; }; EB877554208E91A80985F1E5 /* NPoolsRedeemViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 862545D6BD501914AE04D776 /* NPoolsRedeemViewController.swift */; }; @@ -3796,6 +3933,7 @@ F382BF4F8C3C46C7C21DE5C0 /* ParaStkUnstakeConfirmPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86DCB6F3977BDE1BDC7BC3F9 /* ParaStkUnstakeConfirmPresenter.swift */; }; F3BB50CCA38C9B47FDBEDF53 /* ReferendumVotersInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23CC3812E4DFC26484324D57 /* ReferendumVotersInteractor.swift */; }; F3D2AC37709EAF088A594B73 /* AccountManagementViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FCA2DD3A8898D64CBC9F97 /* AccountManagementViewController.swift */; }; + F3EA441846781BE5D7F3D7AB /* TransferNetworkSelectionProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850BB5254D6443ACFDBB96CC /* TransferNetworkSelectionProtocols.swift */; }; F400A7C2260CE1670061D576 /* StakingRewardStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F400A7C1260CE1670061D576 /* StakingRewardStatus.swift */; }; F402BC83273ACDC30075F803 /* AstarBonusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F402BC82273ACDC30075F803 /* AstarBonusService.swift */; }; F402BC8B273AD20D0075F803 /* AstarBonusServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F402BC8A273AD20D0075F803 /* AstarBonusServiceError.swift */; }; @@ -3923,6 +4061,7 @@ F88D85C73094F6A1FC494D87 /* DAppSearchWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A191B92AD171FDDDD8C30E2 /* DAppSearchWireframe.swift */; }; F8C0CA3DDBCB5E509295F099 /* MarkdownDescriptionViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAF9ED27CF12B7DA8B1378CF /* MarkdownDescriptionViewFactory.swift */; }; F92E73C24AB577F37B35649E /* WalletConnectSessionDetailsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14339E7BE9FE045C6A2AB52 /* WalletConnectSessionDetailsInteractor.swift */; }; + F9CEF01779F811AEEED06C43 /* SwapSlippageViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E965356C7C646CB86BBEBB6 /* SwapSlippageViewLayout.swift */; }; F9E6E306BCE32992EA9ABF3E /* NominationPoolBondMoreConfirmProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894A6BB613EA991A09976B30 /* NominationPoolBondMoreConfirmProtocols.swift */; }; FA0E6F6A12CA290C7079AC6C /* StakingSetupAmountPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F80E6C0C3AC88D7CDB8204F /* StakingSetupAmountPresenter.swift */; }; FA62AACACA15CB04275DE957 /* ParaStkYourCollatorsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21A2FD02269432066884F5AF /* ParaStkYourCollatorsInteractor.swift */; }; @@ -3991,6 +4130,8 @@ 07D08AF2C744DF2073702499 /* DAppListViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DAppListViewFactory.swift; sourceTree = ""; }; 07D165A41A0F3F0EC1926175 /* StakingMoreOptionsProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingMoreOptionsProtocols.swift; sourceTree = ""; }; 080DF5C2C6DEC79D8324F084 /* NPoolsUnstakeConfirmInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NPoolsUnstakeConfirmInteractor.swift; sourceTree = ""; }; + 0816F2A4A5CC1F111E626188 /* SwapSlippagePresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSlippagePresenter.swift; sourceTree = ""; }; + 088C765E5A0F81B96ADE72D8 /* SwapSlippageWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSlippageWireframe.swift; sourceTree = ""; }; 0912F5E8BA170342D52F7D38 /* TransferConfirmViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferConfirmViewController.swift; sourceTree = ""; }; 0988D8EC0768B03BA7C55612 /* ParaStkYieldBoostStartInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkYieldBoostStartInteractor.swift; sourceTree = ""; }; 09E4E9A052F9F04A92F158D6 /* GovernanceDelegateSetupWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceDelegateSetupWireframe.swift; sourceTree = ""; }; @@ -4002,6 +4143,11 @@ 0B62C2CBCFF1865A1CA0F1B4 /* LedgerNetworkSelectionProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LedgerNetworkSelectionProtocols.swift; sourceTree = ""; }; 0BA85EE628D7029AC940DFA3 /* NominationPoolBondMoreBasePresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NominationPoolBondMoreBasePresenter.swift; sourceTree = ""; }; 0C04290B2A67A42A00C3583A /* SubstrateDataModel17.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = SubstrateDataModel17.xcdatamodel; sourceTree = ""; }; + 0C0CB37E2AC540B200EAC516 /* AssetConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetConversion.swift; sourceTree = ""; }; + 0C0CB3812AC545A800EAC516 /* AssetConversionExtrinsicService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetConversionExtrinsicService.swift; sourceTree = ""; }; + 0C0CB3842AC561FB00EAC516 /* AssetHubSwapOperationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetHubSwapOperationFactory.swift; sourceTree = ""; }; + 0C0CB3872AC5688100EAC516 /* AssetConversionPallet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetConversionPallet.swift; sourceTree = ""; }; + 0C0CB3892AC56A1600EAC516 /* AssetConversionPallet+Path.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssetConversionPallet+Path.swift"; sourceTree = ""; }; 0C12A2462AA1D96000C7FA49 /* SubqueryMultistakingTypeFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubqueryMultistakingTypeFactory.swift; sourceTree = ""; }; 0C12A2482AA35AE300C7FA49 /* RelaychainStakingValidatorFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaychainStakingValidatorFacade.swift; sourceTree = ""; }; 0C13380F2AB832B30036BCD6 /* QRImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRImageViewModel.swift; sourceTree = ""; }; @@ -4025,6 +4171,18 @@ 0C13D3202A822B220054BB6F /* StartStakingDirectConfirmWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingDirectConfirmWireframe.swift; sourceTree = ""; }; 0C13D3232A823D810054BB6F /* StartStakingExtrinsicProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingExtrinsicProxy.swift; sourceTree = ""; }; 0C13D3252A82753F0054BB6F /* StartStakingFeeIdFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingFeeIdFactory.swift; sourceTree = ""; }; + 0C13DFC82AF4FFC200E5F355 /* SwapErrorPresentableParams.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapErrorPresentableParams.swift; sourceTree = ""; }; + 0C13DFCA2AF6182500E5F355 /* SwapModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapModel.swift; sourceTree = ""; }; + 0C13DFCC2AF8A5A300E5F355 /* SwapMaxModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapMaxModel.swift; sourceTree = ""; }; + 0C13DFCE2AF8ADB300E5F355 /* BigUInt+Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BigUInt+Operation.swift"; sourceTree = ""; }; + 0C13DFD02AF8AE3E00E5F355 /* SwapBasePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapBasePresenter.swift; sourceTree = ""; }; + 0C13DFD22AF949B400E5F355 /* BalanceViewModelFactoryFacade+Formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BalanceViewModelFactoryFacade+Formatting.swift"; sourceTree = ""; }; + 0C13DFD42AFA4F1500E5F355 /* SwapIssueCheckParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapIssueCheckParams.swift; sourceTree = ""; }; + 0C13DFD62AFA50A200E5F355 /* SwapIssueViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapIssueViewModelFactory.swift; sourceTree = ""; }; + 0C13DFDA2AFA691400E5F355 /* SlippageConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlippageConfig.swift; sourceTree = ""; }; + 0C13DFDC2AFA82BA00E5F355 /* AmountInputViewModel+Slippage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AmountInputViewModel+Slippage.swift"; sourceTree = ""; }; + 0C13DFDE2AFA89EF00E5F355 /* Decimal+Percents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+Percents.swift"; sourceTree = ""; }; + 0C13DFE02AFBBAF600E5F355 /* SwapBaseViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapBaseViewModelFactory.swift; sourceTree = ""; }; 0C154A0D2A45995500932C3F /* CompoundComparator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompoundComparator.swift; sourceTree = ""; }; 0C17BD962A42F162004AF9E7 /* WalletViewModelObserverContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletViewModelObserverContainer.swift; sourceTree = ""; }; 0C17BD982A42F1BE004AF9E7 /* MoneyPresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyPresentable.swift; sourceTree = ""; }; @@ -4039,6 +4197,8 @@ 0C21D77B2AB42A3500EB2DBD /* ScreenOpenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenOpenService.swift; sourceTree = ""; }; 0C21D77D2AB42D4100EB2DBD /* UrlHandlingAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlHandlingAction.swift; sourceTree = ""; }; 0C21D77F2AB432B100EB2DBD /* MainTabBarIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarIndex.swift; sourceTree = ""; }; + 0C2200682ACA80BB0067BA61 /* AssetHubSwapQuoteBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetHubSwapQuoteBuilder.swift; sourceTree = ""; }; + 0C22006D2ACAAC2F0067BA61 /* AssetConversionPallet+Call.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssetConversionPallet+Call.swift"; sourceTree = ""; }; 0C2437D345C3D9B12AEE1E28 /* ParaStkYieldBoostSetupProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkYieldBoostSetupProtocols.swift; sourceTree = ""; }; 0C29B5372A4C68A500E35C6D /* AnimationUpdatibleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimationUpdatibleView.swift; sourceTree = ""; }; 0C2B3C9875FDA7EE8D168900 /* ParaStkYieldBoostSetupWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkYieldBoostSetupWireframe.swift; sourceTree = ""; }; @@ -4055,6 +4215,7 @@ 0C2F86952A72807E00593C01 /* NominationPoolsRewardEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NominationPoolsRewardEngine.swift; sourceTree = ""; }; 0C2F86972A728EE900593C01 /* NPoolsRewardEngineFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NPoolsRewardEngineFactory.swift; sourceTree = ""; }; 0C2F86992A72948100593C01 /* NominationPoolsApyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NominationPoolsApyTests.swift; sourceTree = ""; }; + 0C2FDF182AEB82AA006A6C59 /* AssetHubFeeModelBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetHubFeeModelBuilder.swift; sourceTree = ""; }; 0C3205BA2A8679F0002EB914 /* EvmGasPriceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvmGasPriceProvider.swift; sourceTree = ""; }; 0C3205BD2A867A9C002EB914 /* EvmLegacyGasPriceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvmLegacyGasPriceProvider.swift; sourceTree = ""; }; 0C3205BF2A867DD6002EB914 /* EvmMaxPriorityGasPriceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvmMaxPriorityGasPriceProvider.swift; sourceTree = ""; }; @@ -4084,6 +4245,10 @@ 0C432D57ACFA53F42E574CBD /* TokensManageViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokensManageViewController.swift; sourceTree = ""; }; 0C463FC72A58126A003E71C9 /* UIView+MotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+MotionEffect.swift"; sourceTree = ""; }; 0C463FCF2A592ACD003E71C9 /* PartialInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PartialInterpolatingMotionEffect.swift; sourceTree = ""; }; + 0C500B1E2B04EA9100ABEE70 /* AssetConversionPallet+Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssetConversionPallet+Event.swift"; sourceTree = ""; }; + 0C500B212B05102900ABEE70 /* AssetTxPaymentPallet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetTxPaymentPallet.swift; sourceTree = ""; }; + 0C500B232B0511F400ABEE70 /* ExtrinsicProcessor+CustomFee.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExtrinsicProcessor+CustomFee.swift"; sourceTree = ""; }; + 0C500B262B07314000ABEE70 /* ExtrinsicProcessingResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtrinsicProcessingResult.swift; sourceTree = ""; }; 0C53649F2A4D6EB700990478 /* AssetListBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetListBuilder.swift; sourceTree = ""; }; 0C543E962AAB1B350035F45F /* ElectedAndPrefValidators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElectedAndPrefValidators.swift; sourceTree = ""; }; 0C56B4FA2A4B0C320030F9C9 /* AssetListBaseBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetListBaseBuilder.swift; sourceTree = ""; }; @@ -4164,6 +4329,7 @@ 0C9951C92AE2ABA400B65615 /* AssetListBannerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetListBannerCell.swift; sourceTree = ""; }; 0C9951CE2AE2BAE500B65615 /* PromotionBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionBannerView.swift; sourceTree = ""; }; 0C9951D22AE2DB0200B65615 /* PromotionViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionViewModelFactory.swift; sourceTree = ""; }; + 0C9A7F982AFC9A2B00938CD0 /* SwapPriceDifferenceConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapPriceDifferenceConfig.swift; sourceTree = ""; }; 0C9C642C2A8CE30A004DC078 /* SystemAccountValidating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAccountValidating.swift; sourceTree = ""; }; 0C9C642F2A8D6779004DC078 /* StakingNPoolsPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingNPoolsPresenter.swift; sourceTree = ""; }; 0C9C64312A8D67A0004DC078 /* StakingNPoolsInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingNPoolsInteractor.swift; sourceTree = ""; }; @@ -4171,7 +4337,14 @@ 0C9C64352A8D67FB004DC078 /* StakingNPoolsProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingNPoolsProtocols.swift; sourceTree = ""; }; 0C9C64372A8D6949004DC078 /* NPoolsStakingSharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NPoolsStakingSharedState.swift; sourceTree = ""; }; 0C9C64392A8DF97E004DC078 /* StakingNPoolsError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingNPoolsError.swift; sourceTree = ""; }; + 0C9D87AD2AC708070095FE8C /* AssetHubTokensConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetHubTokensConverter.swift; sourceTree = ""; }; 0C9ECB592A4A9AB400BDCA73 /* AssetListAccountCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetListAccountCell.swift; sourceTree = ""; }; + 0CA50CAE2AFE6094005668CD /* GetTokenOptionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTokenOptionsViewController.swift; sourceTree = ""; }; + 0CA50CB02AFE6F15005668CD /* GetTokenOptionsProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTokenOptionsProtocols.swift; sourceTree = ""; }; + 0CA50CB22AFE6F23005668CD /* GetTokenOptionsPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTokenOptionsPresenter.swift; sourceTree = ""; }; + 0CA50CB42AFE6F31005668CD /* GetTokenOptionsInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTokenOptionsInteractor.swift; sourceTree = ""; }; + 0CA50CB62AFE6F40005668CD /* GetTokenOptionsViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTokenOptionsViewFactory.swift; sourceTree = ""; }; + 0CA7821B2B03D0A9003F562A /* ExtrinsicProcessor+SwapMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExtrinsicProcessor+SwapMatching.swift"; sourceTree = ""; }; 0CAC01542A52E0CC0069413E /* AssetListModelHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetListModelHelpers.swift; sourceTree = ""; }; 0CAC01562A52E1960069413E /* AssetListPresenterHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetListPresenterHelpers.swift; sourceTree = ""; }; 0CAC44A92A7A79C2001EDE61 /* StartStakingInfoParachainWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingInfoParachainWireframe.swift; sourceTree = ""; }; @@ -4194,6 +4367,14 @@ 0CB261F42A9E188300287305 /* NominationPoolsBondExtraCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NominationPoolsBondExtraCall.swift; sourceTree = ""; }; 0CB261F62A9E2D8400287305 /* StackSwitchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackSwitchCell.swift; sourceTree = ""; }; 0CB261F82A9F1F2200287305 /* NPoolsRedeemError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NPoolsRedeemError.swift; sourceTree = ""; }; + 0CB64E592AFE9947008F268F /* GetTokenOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTokenOperation.swift; sourceTree = ""; }; + 0CB64E5B2B009DA9008F268F /* GetTokenOptionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTokenOptionsModel.swift; sourceTree = ""; }; + 0CB64E5D2B00AA8F008F268F /* GetTokenOptionsResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTokenOptionsResult.swift; sourceTree = ""; }; + 0CB64E5F2B00AD83008F268F /* GetTokenOptionsWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTokenOptionsWireframe.swift; sourceTree = ""; }; + 0CB64E612B012E92008F268F /* TransferSetupPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferSetupPeer.swift; sourceTree = ""; }; + 0CB64E642B01E0CC008F268F /* TransferNetworkSelectionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferNetworkSelectionCell.swift; sourceTree = ""; }; + 0CB64E662B01E174008F268F /* TransferNetworkSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferNetworkSelectionViewModel.swift; sourceTree = ""; }; + 0CB64E682B01F798008F268F /* TransferSetupOriginSelectionWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferSetupOriginSelectionWireframe.swift; sourceTree = ""; }; 0CBC29C52A421B5000F7B1F7 /* StakingMainWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingMainWireframe.swift; sourceTree = ""; }; 0CBC29C72A42AE2F00F7B1F7 /* StakingDashboardBuilderResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingDashboardBuilderResult.swift; sourceTree = ""; }; 0CBF5DE62AB1A60500087EBF /* SharedOperationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedOperationStatus.swift; sourceTree = ""; }; @@ -4208,7 +4389,18 @@ 0CC41A9A168AF0F1FBE5F799 /* Pods_novawalletAll_novawallet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_novawalletAll_novawallet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0CC4CCF32A67C9C400F63041 /* Multistaking+NominationPools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Multistaking+NominationPools.swift"; sourceTree = ""; }; 0CC6C8D72AAB401200AD8D9B /* CustomValidatorsFullList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomValidatorsFullList.swift; sourceTree = ""; }; + 0CCA245A2AC6917400AEF23D /* XcmV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmV3.swift; sourceTree = ""; }; + 0CCA245C2AC6918800AEF23D /* XcmV3Junction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmV3Junction.swift; sourceTree = ""; }; + 0CCA245E2AC6974200AEF23D /* XcmV3Multilocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmV3Multilocation.swift; sourceTree = ""; }; + 0CCA24642AC6B51200AEF23D /* AssetHubSwapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetHubSwapTests.swift; sourceTree = ""; }; 0CCE25202A44306200286709 /* TransactionHistoryPhishingFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionHistoryPhishingFilter.swift; sourceTree = ""; }; + 0CD352922ACAD7A500B3E446 /* AssetHubExtrinsicService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetHubExtrinsicService.swift; sourceTree = ""; }; + 0CD352942ACAF59900B3E446 /* BigRational.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigRational.swift; sourceTree = ""; }; + 0CD352962ACAFADA00B3E446 /* AssetConversionOperationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetConversionOperationFactory.swift; sourceTree = ""; }; + 0CD3529A2ACD3E4300B3E446 /* PalletAssets+Call.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PalletAssets+Call.swift"; sourceTree = ""; }; + 0CD3A67B2AEAA3B90059BBEC /* AssetConversionFeeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetConversionFeeService.swift; sourceTree = ""; }; + 0CD3A67D2AEAAB670059BBEC /* AssetHubFeeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetHubFeeService.swift; sourceTree = ""; }; + 0CD3A67F2AEAC3C90059BBEC /* CompoundOperationWrapper+Add.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CompoundOperationWrapper+Add.swift"; sourceTree = ""; }; 0CDFFCC54A504417F4ACE7AA /* NftListInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NftListInteractor.swift; sourceTree = ""; }; 0CE1504F2A6FAC1200B61CC1 /* NominationPoolsPoolSubscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NominationPoolsPoolSubscriptionService.swift; sourceTree = ""; }; 0CE150532A70EA2200B61CC1 /* NominationPoolsSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NominationPoolsSyncTests.swift; sourceTree = ""; }; @@ -4221,10 +4413,21 @@ 0CE629DC2AA9B6BE00E250BD /* RewardDestinationViewModelFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RewardDestinationViewModelFactory.swift; sourceTree = ""; }; 0CE629DF2AA9B70200E250BD /* CalculatedReward.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalculatedReward.swift; sourceTree = ""; }; 0CE629E12AA9BE3000E250BD /* StakingSetupTypeEntityFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingSetupTypeEntityFacade.swift; sourceTree = ""; }; + 0CEB4ECF2AF148500048FD84 /* SubstrateDataModel20.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = SubstrateDataModel20.xcdatamodel; sourceTree = ""; }; + 0CEB4ED02AF14B8B0048FD84 /* SubmoduleNavigationStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmoduleNavigationStrategy.swift; sourceTree = ""; }; + 0CEB4ED22AF1689D0048FD84 /* AssetConversionAggregationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetConversionAggregationFactory.swift; sourceTree = ""; }; + 0CEB4ED42AF20EB90048FD84 /* CancellableCallHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellableCallHelper.swift; sourceTree = ""; }; + 0CEB4ED82AF371EF0048FD84 /* AssetConversionTxPayment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetConversionTxPayment.swift; sourceTree = ""; }; 0CF193D02A843DA9003F12F6 /* StakingTypeBalanceFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StakingTypeBalanceFactory.swift; sourceTree = ""; }; 0CF193D22A84FC7C003F12F6 /* SelectedStakingViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedStakingViewModelFactory.swift; sourceTree = ""; }; 0CF193D42A861925003F12F6 /* PredefinedTimeShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredefinedTimeShortcut.swift; sourceTree = ""; }; 0CF193D62A861D7E003F12F6 /* StartStakingInfoConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingInfoConstants.swift; sourceTree = ""; }; + 0CFA16122B0CD8A0007AF885 /* GovSpentAmountExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovSpentAmountExtractor.swift; sourceTree = ""; }; + 0CFA16152B0CE51E007AF885 /* GovSpentAmountBatchHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovSpentAmountBatchHandler.swift; sourceTree = ""; }; + 0CFA16182B0CE709007AF885 /* UtilityPallet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilityPallet.swift; sourceTree = ""; }; + 0CFA161B2B0CE851007AF885 /* Utility+Calls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Utility+Calls.swift"; sourceTree = ""; }; + 0CFA161D2B0CED07007AF885 /* GovTreasurySpentAmountHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovTreasurySpentAmountHandler.swift; sourceTree = ""; }; + 0CFA161F2B0CEF31007AF885 /* GovTreasuryApproveHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovTreasuryApproveHandler.swift; sourceTree = ""; }; 0D3FE2CE7F9F2836755DBA63 /* GovernanceUnlockConfirmProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceUnlockConfirmProtocols.swift; sourceTree = ""; }; 0D65686560E2E6C18A5C34CB /* StartStakingInfoWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StartStakingInfoWireframe.swift; sourceTree = ""; }; 0D6E67AD564867E121601F18 /* WalletsListPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletsListPresenter.swift; sourceTree = ""; }; @@ -4310,6 +4513,7 @@ 270B309EC85D8897A4ADD98A /* CustomValidatorListViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CustomValidatorListViewController.swift; sourceTree = ""; }; 27A5489E97F846FE3D5931E5 /* ParaStkYieldBoostStopViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkYieldBoostStopViewFactory.swift; sourceTree = ""; }; 27D5AF2F7609ADE855308089 /* AccountExportPasswordViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountExportPasswordViewController.swift; sourceTree = ""; }; + 28294C13CF8F62D2FE4D0427 /* SwapSlippageViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSlippageViewFactory.swift; sourceTree = ""; }; 285212895DBAB0098F302DF9 /* ParaStkYieldBoostStopWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkYieldBoostStopWireframe.swift; sourceTree = ""; }; 2864B5E1A5AFC6A8C1B4B9E5 /* NPoolsUnstakeSetupViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NPoolsUnstakeSetupViewController.swift; sourceTree = ""; }; 2876FA98B3E8F7EBCF5DEED0 /* StartStakingConfirmWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StartStakingConfirmWireframe.swift; sourceTree = ""; }; @@ -4341,15 +4545,18 @@ 2AD0A18F25D3D1E100312428 /* GitHubPhishingServiceFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubPhishingServiceFactory.swift; sourceTree = ""; }; 2AD0A19425D3D3EC00312428 /* GitHubOperationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubOperationFactory.swift; sourceTree = ""; }; 2ADA652B86975A2044ABB065 /* NominationPoolBondMoreConfirmViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NominationPoolBondMoreConfirmViewController.swift; sourceTree = ""; }; + 2AE0E677E64DAC7E93562412 /* SwapSlippageProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSlippageProtocols.swift; sourceTree = ""; }; 2AF8204E274FD2110092E3E7 /* BaseAccountCreatePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAccountCreatePresenter.swift; sourceTree = ""; }; 2AFF4B9F274D1E4D00D790B4 /* UsernameSetupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameSetupViewController.swift; sourceTree = ""; }; 2AFF4BA1274D1E5C00D790B4 /* UsernameSetupViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameSetupViewLayout.swift; sourceTree = ""; }; 2BE0492B98AB9C1540846B39 /* StakingPayoutConfirmationViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingPayoutConfirmationViewFactory.swift; sourceTree = ""; }; 2C3F5511D2E7AC283FF021E8 /* GovernanceRevokeDelegationConfirmProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceRevokeDelegationConfirmProtocols.swift; sourceTree = ""; }; 2DC70DB97D2E9350022A899B /* TokensManagePresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokensManagePresenter.swift; sourceTree = ""; }; + 2DEED7526468089FE8A8989C /* SwapConfirmInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapConfirmInteractor.swift; sourceTree = ""; }; 2E4B0600AFFB96A75CF98755 /* StakingRedeemProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingRedeemProtocols.swift; sourceTree = ""; }; 2E5C5EE99A4B73789BE23039 /* Pods-novawalletAll-novawalletIntegrationTests.staging.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-novawalletAll-novawalletIntegrationTests.staging.xcconfig"; path = "Target Support Files/Pods-novawalletAll-novawalletIntegrationTests/Pods-novawalletAll-novawalletIntegrationTests.staging.xcconfig"; sourceTree = ""; }; 2E800814C025B38C87CC282D /* TokensAddSelectNetworkViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokensAddSelectNetworkViewFactory.swift; sourceTree = ""; }; + 2E965356C7C646CB86BBEBB6 /* SwapSlippageViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSlippageViewLayout.swift; sourceTree = ""; }; 2ECD8589BD30A8BE9492AD87 /* StakingRewardPayoutsPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingRewardPayoutsPresenter.swift; sourceTree = ""; }; 2F10F130391C4B3652FE8F59 /* ParitySignerTxScanProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParitySignerTxScanProtocols.swift; sourceTree = ""; }; 2F5A8E5D1C5A7F0B5B1570B9 /* NominationPoolBondMoreConfirmViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NominationPoolBondMoreConfirmViewLayout.swift; sourceTree = ""; }; @@ -4397,6 +4604,7 @@ 37C7955BBDC93BC07D069B8F /* ParaStkStakeSetupViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkStakeSetupViewFactory.swift; sourceTree = ""; }; 37D17FFDACA017C3460DE280 /* LedgerNetworkSelectionPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LedgerNetworkSelectionPresenter.swift; sourceTree = ""; }; 37FF46AB59967BF656E9EF1C /* AssetsSettingsViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AssetsSettingsViewFactory.swift; sourceTree = ""; }; + 382B8763E4FDA7C4E2EF06A8 /* SwapConfirmViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapConfirmViewLayout.swift; sourceTree = ""; }; 38BBECA4E3F54769D95DD21E /* TokensAddSelectNetworkViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokensAddSelectNetworkViewController.swift; sourceTree = ""; }; 392B5AA43C68E640C9FDEE04 /* LedgerAccountConfirmationViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LedgerAccountConfirmationViewFactory.swift; sourceTree = ""; }; 3930902D540DB0B9A2CFD21C /* StakingTypeViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingTypeViewController.swift; sourceTree = ""; }; @@ -4458,9 +4666,11 @@ 47C56F2A76BA8745F1F708D4 /* DAppWalletAuthPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DAppWalletAuthPresenter.swift; sourceTree = ""; }; 4809C2DB0C15A9B2890C1AC6 /* LedgerPerformOperationInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LedgerPerformOperationInteractor.swift; sourceTree = ""; }; 48182DE3A1302757558031FD /* TokenManageSinglePresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokenManageSinglePresenter.swift; sourceTree = ""; }; + 486BF5FBE285713D05CF95A8 /* TransferNetworkSelectionInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNetworkSelectionInteractor.swift; sourceTree = ""; }; 48C158C8D1855BCE53636934 /* AccountCreateProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountCreateProtocols.swift; sourceTree = ""; }; 48CECA2C5A0EFEBFDBB3C90C /* DAppOperationConfirmWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DAppOperationConfirmWireframe.swift; sourceTree = ""; }; 48E5BB1EB494B5DB92FC3053 /* Pods-novawalletAll-novawalletIntegrationTests.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-novawalletAll-novawalletIntegrationTests.dev.xcconfig"; path = "Target Support Files/Pods-novawalletAll-novawalletIntegrationTests/Pods-novawalletAll-novawalletIntegrationTests.dev.xcconfig"; sourceTree = ""; }; + 48E6BE303472080271AAC917 /* SwapConfirmPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapConfirmPresenter.swift; sourceTree = ""; }; 4A191B92AD171FDDDD8C30E2 /* DAppSearchWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DAppSearchWireframe.swift; sourceTree = ""; }; 4B243F6751E2277D9FC14481 /* AdvancedWalletViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AdvancedWalletViewFactory.swift; sourceTree = ""; }; 4B6060CE86A7EA49AD05329C /* ParaStkUnstakeConfirmViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkUnstakeConfirmViewController.swift; sourceTree = ""; }; @@ -4495,6 +4705,7 @@ 53235E51143C6E93303E30FE /* DAppSearchViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DAppSearchViewController.swift; sourceTree = ""; }; 534173384708B3CF8F47E4DA /* GovernanceDelegateConfirmPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceDelegateConfirmPresenter.swift; sourceTree = ""; }; 536D1CA47A5753B9E0389BEA /* ReferendumSearchProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReferendumSearchProtocols.swift; sourceTree = ""; }; + 53775773F2060B4B7F6D62DA /* SwapSetupProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSetupProtocols.swift; sourceTree = ""; }; 537CCA1F2667A51731C56C88 /* CreateWatchOnlyProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreateWatchOnlyProtocols.swift; sourceTree = ""; }; 53A058D4A585F253CBF2968D /* ReferendumVoteSetupViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReferendumVoteSetupViewController.swift; sourceTree = ""; }; 53B5C1C920BFDA8F5C9C89D9 /* ParaStkRedeemInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkRedeemInteractor.swift; sourceTree = ""; }; @@ -4578,6 +4789,7 @@ 6A3105383F2825940D0105D5 /* ReferendumVoteSetupViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReferendumVoteSetupViewLayout.swift; sourceTree = ""; }; 6A4009AE7EF95E9CE1EB88B8 /* NominationPoolBondMoreBaseWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NominationPoolBondMoreBaseWireframe.swift; sourceTree = ""; }; 6A695CA303926DFB5D54E309 /* LedgerAccountConfirmationViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LedgerAccountConfirmationViewLayout.swift; sourceTree = ""; }; + 6A6BCE8F8DB7576E1E5B5974 /* SwapSetupViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSetupViewFactory.swift; sourceTree = ""; }; 6A7302440137F083F7AEC64E /* NPoolsClaimRewardsViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NPoolsClaimRewardsViewLayout.swift; sourceTree = ""; }; 6A825B6368073B06F32D7C8F /* StakingMainViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingMainViewFactory.swift; sourceTree = ""; }; 6AD8B98AB03AAF06AA891695 /* TransferConfirmViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferConfirmViewLayout.swift; sourceTree = ""; }; @@ -4624,6 +4836,19 @@ 770F578A2A8A48FF005FD7C1 /* ButtonViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonViewModel.swift; sourceTree = ""; }; 77171CA72A98BBA10032B387 /* NominationPoolErrorPresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NominationPoolErrorPresentable.swift; sourceTree = ""; }; 77171CA92A98BC420032B387 /* NominationPoolDataValidatorFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NominationPoolDataValidatorFactory.swift; sourceTree = ""; }; + 7719018B2AE0E70B00D9C918 /* SwapDataValidatorFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapDataValidatorFactory.swift; sourceTree = ""; }; + 7719018D2AE0E71F00D9C918 /* SwapErrorPresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapErrorPresentable.swift; sourceTree = ""; }; + 7719018F2AE2424B00D9C918 /* SwapsValidationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapsValidationTests.swift; sourceTree = ""; }; + 7719019A2AE670AE00D9C918 /* ShortTextInfoPresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortTextInfoPresentable.swift; sourceTree = ""; }; + 7719019C2AE6996600D9C918 /* SwapPairView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapPairView.swift; sourceTree = ""; }; + 7719019E2AE6C9DC00D9C918 /* SwapElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapElementView.swift; sourceTree = ""; }; + 771901A12AE7E34D00D9C918 /* SwapBaseInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapBaseInteractor.swift; sourceTree = ""; }; + 771901A32AE7E48800D9C918 /* SwapBaseProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapBaseProtocols.swift; sourceTree = ""; }; + 771901A52AE8FF7E00D9C918 /* SwapInfoViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapInfoViewCell.swift; sourceTree = ""; }; + 771901A72AE8FF9F00D9C918 /* SwapNetworkFeeViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapNetworkFeeViewCell.swift; sourceTree = ""; }; + 771901AA2AE9581400D9C918 /* SwapConfirmViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapConfirmViewModelFactory.swift; sourceTree = ""; }; + 771901AF2AE97DA500D9C918 /* SwapConfirmViewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapConfirmViewModels.swift; sourceTree = ""; }; + 771901B12AEA3E5100D9C918 /* ShortTextInfoPresentableExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortTextInfoPresentableExtensions.swift; sourceTree = ""; }; 77204EA32A1CD59100BBDE4A /* GenericBorderedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericBorderedView.swift; sourceTree = ""; }; 77204EA52A1E0EAA00BBDE4A /* WalletConnectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletConnectionsView.swift; sourceTree = ""; }; 7725062B2A1C99DB00E653DB /* ReferendumSearchViewLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReferendumSearchViewLayout.swift; sourceTree = ""; }; @@ -4641,11 +4866,21 @@ 772B1C7C2A93837800A19061 /* StartStakingCustomValidatorListWireframe.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartStakingCustomValidatorListWireframe.swift; sourceTree = ""; }; 7731E9C32A14DA3F0085B5FF /* BorderedActionControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BorderedActionControlView.swift; sourceTree = ""; }; 7738FB692A4C5D1A00797439 /* StartStakingRelaychainInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingRelaychainInteractor.swift; sourceTree = ""; }; + 774091F82ACB1F4B00172516 /* SwapAmountInputView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapAmountInputView.swift; sourceTree = ""; }; + 774091FB2ACC053000172516 /* SwapAssetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapAssetView.swift; sourceTree = ""; }; + 774091FD2ACC054B00172516 /* SwapAssetControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapAssetControl.swift; sourceTree = ""; }; + 774091FF2ACC1BE400172516 /* SwapAmountInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapAmountInput.swift; sourceTree = ""; }; 774A481029F8BFB70094635B /* OperationAuthPresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationAuthPresentable.swift; sourceTree = ""; }; 7756927C2A20B88200220756 /* TokenOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenOperation.swift; sourceTree = ""; }; 775F194C2A56EEAC009915B6 /* StartStakingInfoRelaychainPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingInfoRelaychainPresenter.swift; sourceTree = ""; }; 775F19502A5811FA009915B6 /* StartStakingParachainInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingParachainInteractor.swift; sourceTree = ""; }; 775F19522A5BDFB6009915B6 /* StartStakingInfoParachainPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingInfoParachainPresenter.swift; sourceTree = ""; }; + 77740BBB2AD4A7B800E8C06F /* CollapsableContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsableContainerView.swift; sourceTree = ""; }; + 77740BBD2AD4A7F500E8C06F /* SwapDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapDetailsView.swift; sourceTree = ""; }; + 77740BBF2AD4A80D00E8C06F /* SwapInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapInfoView.swift; sourceTree = ""; }; + 77740BC12AD69E3400E8C06F /* SwapMaxButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapMaxButtonView.swift; sourceTree = ""; }; + 77740BC32AD8145500E8C06F /* PercentInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PercentInputView.swift; sourceTree = ""; }; + 77740BC52AD849D100E8C06F /* SlippagePercentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlippagePercentViewModel.swift; sourceTree = ""; }; 77799ADC2A74219A00B7E564 /* ButtonState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonState.swift; sourceTree = ""; }; 77799ADE2A78DAB200B7E564 /* StartStakingInfoRelaychainWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartStakingInfoRelaychainWireframe.swift; sourceTree = ""; }; 77799AE42A792AE700B7E564 /* StakingTypeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingTypeViewModel.swift; sourceTree = ""; }; @@ -4692,6 +4927,8 @@ 77A0B2F22A3CA37E00CBF653 /* StakingMoreOptionsViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingMoreOptionsViewModelFactory.swift; sourceTree = ""; }; 77A0B2F42A3CA39D00CBF653 /* StakingMoreOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingMoreOptionCollectionViewCell.swift; sourceTree = ""; }; 77A0B2F82A3CA40E00CBF653 /* StakingMoreOptionsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingMoreOptionsSection.swift; sourceTree = ""; }; + 77A4F4002B035027006294BC /* AssetOperationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetOperationState.swift; sourceTree = ""; }; + 77A4F4022B036615006294BC /* Optional+Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Result.swift"; sourceTree = ""; }; 77A6F5AA2A2E0B31004AFD1A /* ReceiveAssetOperationPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReceiveAssetOperationPresenter.swift; sourceTree = ""; }; 77A6F5AD2A2E0C7C004AFD1A /* ReceiveAssetOperationWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveAssetOperationWireframe.swift; sourceTree = ""; }; 77A6F5B72A2E2AAD004AFD1A /* BuyAssetOperationPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuyAssetOperationPresenter.swift; sourceTree = ""; }; @@ -4705,9 +4942,29 @@ 77A6F5CE2A31C4D4004AFD1A /* Web3TransferRecipientRepositoryFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Web3TransferRecipientRepositoryFactory.swift; sourceTree = ""; }; 77A6F5D12A31DB8C004AFD1A /* JsonCanonicalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonCanonicalizer.swift; sourceTree = ""; }; 77A6F5D42A31E046004AFD1A /* JsonCanonicalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonCanonicalizerTests.swift; sourceTree = ""; }; + 77AAE21F2AFB00CB006872CC /* OperationSwapModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationSwapModel.swift; sourceTree = ""; }; + 77AAE2212AFB026E006872CC /* OperationDetailsSwapProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationDetailsSwapProvider.swift; sourceTree = ""; }; + 77AAE2232AFB67BE006872CC /* OperationSwapDetailsInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationSwapDetailsInteractor.swift; sourceTree = ""; }; + 77AAE2252AFC10EE006872CC /* PriceHistoryCalculatorFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceHistoryCalculatorFactory.swift; sourceTree = ""; }; + 77AAE2272AFC1167006872CC /* ChainModel+historyId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChainModel+historyId.swift"; sourceTree = ""; }; + 77AAE2292AFC36EE006872CC /* OperationDetailsBaseInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationDetailsBaseInteractor.swift; sourceTree = ""; }; + 77AAE22B2AFCD7AA006872CC /* SubstrateDataModel21.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = SubstrateDataModel21.xcdatamodel; sourceTree = ""; }; 77AB55582AA244BB0058814E /* OperationPoolRewardOrSlashModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationPoolRewardOrSlashModel.swift; sourceTree = ""; }; 77AB555A2AA246CA0058814E /* OperationPoolRewardOrSlashViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationPoolRewardOrSlashViewModel.swift; sourceTree = ""; }; 77AB555C2AA24BA90058814E /* OperationDetailsPoolRewardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationDetailsPoolRewardView.swift; sourceTree = ""; }; + 77C9761F2AF36A170049272C /* SwapModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapModels.swift; sourceTree = ""; }; + 77C976212AF39F180049272C /* TokenOperationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenOperationTableViewCell.swift; sourceTree = ""; }; + 77C976232AF3A5280049272C /* SwapViewModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapViewModels.swift; sourceTree = ""; }; + 77C976252AF421AE0049272C /* OperationDetailsSwapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationDetailsSwapView.swift; sourceTree = ""; }; + 77C976272AF426100049272C /* OperationSwapViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationSwapViewModel.swift; sourceTree = ""; }; + 77C9BCBD2ACD286100022EA2 /* SwapsSetupViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapsSetupViewModelFactory.swift; sourceTree = ""; }; + 77C9BCC32ACD570100022EA2 /* SwapAssetsOperationInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapAssetsOperationInteractor.swift; sourceTree = ""; }; + 77C9BCC52ACD571400022EA2 /* SwapAssetOperationPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapAssetOperationPresenter.swift; sourceTree = ""; }; + 77C9BCC92ACD574800022EA2 /* SwapAssetOperationWireframe.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapAssetOperationWireframe.swift; sourceTree = ""; }; + 77C9BCCB2ACD576B00022EA2 /* SwapAssetsOperationProtocols.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapAssetsOperationProtocols.swift; sourceTree = ""; }; + 77C9BCCD2ACD691300022EA2 /* SwapAssetsOperationViewFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapAssetsOperationViewFactory.swift; sourceTree = ""; }; + 77C9BCCF2ACD8A3600022EA2 /* SwapAssetsOperationViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapAssetsOperationViewLayout.swift; sourceTree = ""; }; + 77C9BCD12ACD8AF400022EA2 /* SwapAssetsOperationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapAssetsOperationViewController.swift; sourceTree = ""; }; 77CB33CD2A38780700B6709A /* structures_output.json */ = {isa = PBXFileReference; explicitFileType = text.json; fileEncoding = 4; path = structures_output.json; sourceTree = ""; usesTabs = 0; wrapsLines = 0; }; 77CB33D12A38893900B6709A /* Web3NameIntegrityVerifierWithCanonicalizationData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Web3NameIntegrityVerifierWithCanonicalizationData.swift; sourceTree = ""; }; 77CB33D62A3998FC00B6709A /* Array+Sort.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Array+Sort.swift"; sourceTree = ""; }; @@ -4717,10 +4974,19 @@ 77CC82A22A984BC3002D022F /* StartStakingSelectedValidatorsListWireframe.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartStakingSelectedValidatorsListWireframe.swift; sourceTree = ""; }; 77CC82A42A984EDA002D022F /* UINavigaionController+Pop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigaionController+Pop.swift"; sourceTree = ""; }; 77CC82A62A986CF1002D022F /* StakingSelectValidatorsDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingSelectValidatorsDelegate.swift; sourceTree = ""; }; + 77D2E2702B05416E0098F188 /* AssetListGroupModelCompator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetListGroupModelCompator.swift; sourceTree = ""; }; + 77D2E2722B0542A50098F188 /* AmountPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountPair.swift; sourceTree = ""; }; 77E0DC9D2A6940C400D03724 /* Calendar+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Calendar+Helpers.swift"; sourceTree = ""; }; 77E255652A16059A00B644C3 /* MultiassetUserDataModel9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MultiassetUserDataModel9.xcdatamodel; sourceTree = ""; }; 77E255662A16145500B644C3 /* StakingRewardsFilterMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingRewardsFilterMapper.swift; sourceTree = ""; }; 77E255682A16148000B644C3 /* StakingRewardsFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingRewardsFilter.swift; sourceTree = ""; }; + 77E304A82AEB9F76006FD6F0 /* SwapConfirmInitState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapConfirmInitState.swift; sourceTree = ""; }; + 77E304AA2AEBB214006FD6F0 /* SlippageBounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlippageBounds.swift; sourceTree = ""; }; + 77E304AC2AEC16B2006FD6F0 /* SwapPriceDifferenceViewModelFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapPriceDifferenceViewModelFactoryProtocol.swift; sourceTree = ""; }; + 77E304AF2AEFA261006FD6F0 /* SwapNetworkFeeSheetLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapNetworkFeeSheetLayout.swift; sourceTree = ""; }; + 77E304B12AEFC0F3006FD6F0 /* SwapNetworkFeeSheetViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapNetworkFeeSheetViewController.swift; sourceTree = ""; }; + 77E304B42AEFC303006FD6F0 /* SwapNetworkFeeSheetViewFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapNetworkFeeSheetViewFactory.swift; sourceTree = ""; }; + 77E304B62AEFC348006FD6F0 /* SwapNetworkFeeSheetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapNetworkFeeSheetViewModel.swift; sourceTree = ""; }; 77EA2A182A333C1500B0670B /* french_output.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = french_output.json; sourceTree = ""; }; 77EA2A192A333C1500B0670B /* arrays_output.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = arrays_output.json; sourceTree = ""; }; 77EA2A1A2A333C1500B0670B /* weird_output.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = weird_output.json; sourceTree = ""; }; @@ -4728,6 +4994,7 @@ 77EA2A1F2A333C1500B0670B /* arrays_input.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = arrays_input.json; sourceTree = ""; }; 77EA2A202A333C1500B0670B /* weird_input.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = weird_input.json; sourceTree = ""; }; 77EA2A212A333C1500B0670B /* structures_input.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = structures_input.json; sourceTree = ""; }; + 77ECB46F2ACEEE2D0015CE9F /* SwapNetworkFeeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapNetworkFeeView.swift; sourceTree = ""; }; 77ED16792A0CF41600E1FC8C /* StakingRewardFiltersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingRewardFiltersViewModel.swift; sourceTree = ""; }; 77ED167B2A0CF42E00E1FC8C /* StakingRewardFiltersPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakingRewardFiltersPeriod.swift; sourceTree = ""; }; 77ED167D2A0D0AE900E1FC8C /* Lenses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lenses.swift; sourceTree = ""; }; @@ -4769,6 +5036,7 @@ 7911693957DFAF141EBDAFEC /* StakingRewardPayoutsProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingRewardPayoutsProtocols.swift; sourceTree = ""; }; 793046EA14E4CAB096803BCD /* NftDetailsWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NftDetailsWireframe.swift; sourceTree = ""; }; 793356FB65FB73CE7097C6F1 /* NPoolsUnstakeConfirmProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NPoolsUnstakeConfirmProtocols.swift; sourceTree = ""; }; + 796859464B823B60746C5DE5 /* SwapConfirmProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapConfirmProtocols.swift; sourceTree = ""; }; 7A092ADC09DA0429548EBC08 /* NftListPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NftListPresenter.swift; sourceTree = ""; }; 7ACF32611D345B87BCE29FE0 /* DAppAddFavoriteWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DAppAddFavoriteWireframe.swift; sourceTree = ""; }; 7B13D65B93E65B5112272962 /* DelegationReferendumVotersWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DelegationReferendumVotersWireframe.swift; sourceTree = ""; }; @@ -4778,6 +5046,7 @@ 7C2883649A5E84DF9B861C83 /* DelegateVotedReferendaViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DelegateVotedReferendaViewController.swift; sourceTree = ""; }; 7C70EBF83B2547452417E588 /* StakingRewardDetailsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingRewardDetailsViewController.swift; sourceTree = ""; }; 7CBA1296E4C6E04EC9C5CA98 /* ParaStkUnstakePresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkUnstakePresenter.swift; sourceTree = ""; }; + 7CD1FBC9C063951E2520265D /* SwapConfirmWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapConfirmWireframe.swift; sourceTree = ""; }; 7D8A9948F319AD0D154D1EC4 /* DelegationListViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DelegationListViewFactory.swift; sourceTree = ""; }; 7DDDB2B35CD3299F50613141 /* ReferralCrowdloanViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReferralCrowdloanViewController.swift; sourceTree = ""; }; 7DEDFCF115BB1015C928F3B2 /* ParitySignerWelcomeViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParitySignerWelcomeViewLayout.swift; sourceTree = ""; }; @@ -5122,7 +5391,7 @@ 842AEB80292F34B600C61B0C /* RemoteChainExternalApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteChainExternalApi.swift; sourceTree = ""; }; 842B17FA28648FDC0014CC57 /* ChainAssetViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainAssetViewModelFactory.swift; sourceTree = ""; }; 842B17FC2864980B0014CC57 /* NetworkSelectionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSelectionTableViewCell.swift; sourceTree = ""; }; - 842B17FE28649CCD0014CC57 /* CrossChainDestinationSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossChainDestinationSelectionState.swift; sourceTree = ""; }; + 842B17FE28649CCD0014CC57 /* CrossChainSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossChainSelectionState.swift; sourceTree = ""; }; 842B18012864F9950014CC57 /* CrossChainTransferPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossChainTransferPresenter.swift; sourceTree = ""; }; 842B18032864F9A90014CC57 /* CrossChainTransferInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossChainTransferInteractor.swift; sourceTree = ""; }; 842B1805286506EE0014CC57 /* CrossChainTransferSetupProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossChainTransferSetupProtocols.swift; sourceTree = ""; }; @@ -6105,7 +6374,6 @@ 84948C35287DD1C800E6DD3E /* NftListRMRKV2ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NftListRMRKV2ViewModel.swift; sourceTree = ""; }; 84948C37287E0B4F00E6DD3E /* FilterImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterImageProcessor.swift; sourceTree = ""; }; 8494D86A25247F9600614D8F /* Decimal+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+String.swift"; sourceTree = ""; }; - 849528E226036997009DC845 /* RewardEstimationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RewardEstimationView.swift; sourceTree = ""; }; 84953F652934C7D90033F47D /* EtherscanERC20HistoryResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtherscanERC20HistoryResponse.swift; sourceTree = ""; }; 84953F672934C8A70033F47D /* EtherscanERC20HistoryInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtherscanERC20HistoryInfo.swift; sourceTree = ""; }; 84953F692934C9E20033F47D /* EtherscanERC20OperationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtherscanERC20OperationFactory.swift; sourceTree = ""; }; @@ -6983,7 +7251,6 @@ 84FB9E1B285C57EF00B42FC0 /* XcmMultilocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmMultilocation.swift; sourceTree = ""; }; 84FB9E1D285C58FF00B42FC0 /* Xcm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcm.swift; sourceTree = ""; }; 84FB9E1F285C5C9E00B42FC0 /* XcmJunction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmJunction.swift; sourceTree = ""; }; - 84FB9E21285C5D6200B42FC0 /* XcmNetworkId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmNetworkId.swift; sourceTree = ""; }; 84FB9E23285C6ACC00B42FC0 /* XcmMultiasset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmMultiasset.swift; sourceTree = ""; }; 84FB9E25285C6C5000B42FC0 /* XcmVersionedMultiasset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmVersionedMultiasset.swift; sourceTree = ""; }; 84FB9E27285C736A00B42FC0 /* XcmTransferFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmTransferFactory.swift; sourceTree = ""; }; @@ -7028,6 +7295,7 @@ 84FFE45A2862076F002432BB /* XcmUnweightedTransferRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmUnweightedTransferRequest.swift; sourceTree = ""; }; 84FFE45C28620833002432BB /* XcmTransferResolutionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcmTransferResolutionService.swift; sourceTree = ""; }; 84FFE504261290830054EA63 /* NetworkInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInfoView.swift; sourceTree = ""; }; + 850BB5254D6443ACFDBB96CC /* TransferNetworkSelectionProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNetworkSelectionProtocols.swift; sourceTree = ""; }; 855FB8DD761E110A42435A02 /* AccountManagementWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountManagementWireframe.swift; sourceTree = ""; }; 856BF961EACEB9703B2B37C7 /* GovernanceUnlockSetupWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceUnlockSetupWireframe.swift; sourceTree = ""; }; 859E0EF774DF0D498FEF8FCB /* DAppOperationConfirmViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DAppOperationConfirmViewLayout.swift; sourceTree = ""; }; @@ -7389,8 +7657,10 @@ 9B754B68D6F1D1ED5C8577A5 /* AssetListViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AssetListViewFactory.swift; sourceTree = ""; }; 9BAE0A2542AE83345BCCA549 /* TransactionHistoryViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransactionHistoryViewController.swift; sourceTree = ""; }; 9BCCD837A377C237C18B117E /* OperationDetailsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationDetailsViewController.swift; sourceTree = ""; }; + 9CA463769D0F411429780D7D /* TransferNetworkSelectionViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNetworkSelectionViewFactory.swift; sourceTree = ""; }; 9CDC7A44F6B01FE389F34C3A /* ParaStkStakeConfirmInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkStakeConfirmInteractor.swift; sourceTree = ""; }; 9D16A60434C1D9929E65998B /* ParaStkCollatorInfoViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkCollatorInfoViewFactory.swift; sourceTree = ""; }; + 9D6106D11494F979A46F7B4C /* TransferNetworkSelectionViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNetworkSelectionViewController.swift; sourceTree = ""; }; 9D9131BF410C62A93646CA0A /* NPoolsUnstakeConfirmPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NPoolsUnstakeConfirmPresenter.swift; sourceTree = ""; }; 9D93D6B6DB7BACFEA6F2738C /* ParaStkCollatorInfoInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkCollatorInfoInteractor.swift; sourceTree = ""; }; 9DBACA1AB17E90565F133C19 /* WalletsListInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletsListInteractor.swift; sourceTree = ""; }; @@ -7570,6 +7840,7 @@ AEFED3DAA18BCEF0BFA15728 /* SelectValidatorsStartInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SelectValidatorsStartInteractor.swift; sourceTree = ""; }; AF01941105BCD02536538362 /* CrowdloanContributionConfirmProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrowdloanContributionConfirmProtocols.swift; sourceTree = ""; }; AF34514F17CE47AF0C5A66F6 /* TransferConfirmWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferConfirmWireframe.swift; sourceTree = ""; }; + AF5DEDDC53639DFCF524D794 /* SwapConfirmViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapConfirmViewFactory.swift; sourceTree = ""; }; AF7E7AAE54BF5E6A35BDD29B /* GovernanceDelegateInfoInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceDelegateInfoInteractor.swift; sourceTree = ""; }; AFC1FCEA168E40235A1D3EA6 /* GovernanceDelegateSearchViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceDelegateSearchViewController.swift; sourceTree = ""; }; B03974ECD8AEF39FDCA277D7 /* LedgerNetworkSelectionViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LedgerNetworkSelectionViewFactory.swift; sourceTree = ""; }; @@ -7611,6 +7882,8 @@ BAB2478DE3AF0885A3ED7ED8 /* StakingRedeemPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingRedeemPresenter.swift; sourceTree = ""; }; BAF9ED27CF12B7DA8B1378CF /* MarkdownDescriptionViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownDescriptionViewFactory.swift; sourceTree = ""; }; BB1A1934B76A5DCC65855EE1 /* TransactionHistoryWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransactionHistoryWireframe.swift; sourceTree = ""; }; + BB494F0B16C9588325CF0D84 /* SwapSetupPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSetupPresenter.swift; sourceTree = ""; }; + BBEB2BC52266AF493D310834 /* SwapSetupViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSetupViewController.swift; sourceTree = ""; }; BC15D0B7B9F29E97FCECC1D2 /* AssetsSettingsViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AssetsSettingsViewLayout.swift; sourceTree = ""; }; BC216C4DBF86A9F3ADB3AECF /* ParitySignerAddConfirmWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParitySignerAddConfirmWireframe.swift; sourceTree = ""; }; BC767DE76479F70A8FA3292A /* StakingMoreOptionsViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingMoreOptionsViewLayout.swift; sourceTree = ""; }; @@ -7640,8 +7913,10 @@ C4E807E9E12A130C50E8FFDF /* StakingDashboardViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StakingDashboardViewFactory.swift; sourceTree = ""; }; C503100478AB56E903598A78 /* ReferralCrowdloanPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReferralCrowdloanPresenter.swift; sourceTree = ""; }; C52D6675524DB913210F0459 /* DAppSettingsPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DAppSettingsPresenter.swift; sourceTree = ""; }; + C585109AC3A2580AB1253C31 /* SwapSetupInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSetupInteractor.swift; sourceTree = ""; }; C5E9D289393AA2CC1E34C2F4 /* AssetDetailsWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AssetDetailsWireframe.swift; sourceTree = ""; }; C74A2166B054240BD5D925B6 /* UsernameSetupViewFactory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsernameSetupViewFactory.swift; sourceTree = ""; }; + C7F910E56C2CC7AA5224BD21 /* SwapSetupWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSetupWireframe.swift; sourceTree = ""; }; C80D934D47929D2331111AD7 /* ReferendumFullDetailsWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReferendumFullDetailsWireframe.swift; sourceTree = ""; }; C92B3D5B314FB3EAE65FA471 /* StartStakingInfoProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StartStakingInfoProtocols.swift; sourceTree = ""; }; C96C3B5ABF4A8124848EFD17 /* ControllerAccountConfirmationWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ControllerAccountConfirmationWireframe.swift; sourceTree = ""; }; @@ -7650,6 +7925,7 @@ C9990DF2F0214CD51E5388CE /* ReferendumVotersPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReferendumVotersPresenter.swift; sourceTree = ""; }; C9A0E36C6D08351DFF7263E5 /* CommonDelegationTracksViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommonDelegationTracksViewLayout.swift; sourceTree = ""; }; CAB80E4CA0D5FA56612318A2 /* ChangeWatchOnlyProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChangeWatchOnlyProtocols.swift; sourceTree = ""; }; + CAE1DE626A1960428C9AEC52 /* TransferNetworkSelectionPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNetworkSelectionPresenter.swift; sourceTree = ""; }; CAEF44ADECD66B49E3430365 /* MarkdownDescriptionPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownDescriptionPresenter.swift; sourceTree = ""; }; CB441F15E16B07196DD9CE9D /* ParaStkUnstakeProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkUnstakeProtocols.swift; sourceTree = ""; }; CB9150FEC66FC503CF1BD1D0 /* WalletHistoryFilterPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletHistoryFilterPresenter.swift; sourceTree = ""; }; @@ -7662,6 +7938,7 @@ CD6B5B187E83839481846C7E /* NftDetailsInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NftDetailsInteractor.swift; sourceTree = ""; }; CD7A6C62EC06FC3B2693FB43 /* GovernanceDelegateSetupViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceDelegateSetupViewLayout.swift; sourceTree = ""; }; CDB47990BC7A594E663DAC00 /* ReferendumVoteConfirmPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReferendumVoteConfirmPresenter.swift; sourceTree = ""; }; + CDD43373A9159E0A592077BB /* SwapSetupViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSetupViewLayout.swift; sourceTree = ""; }; CE98454DC77EAA01301B9BBF /* ParaStkCollatorFiltersProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkCollatorFiltersProtocols.swift; sourceTree = ""; }; CF389223A781CA2088C7A4DD /* NPoolsClaimRewardsWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NPoolsClaimRewardsWireframe.swift; sourceTree = ""; }; CF7A019F89C6CD418AEEE79C /* YourWalletsPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = YourWalletsPresenter.swift; sourceTree = ""; }; @@ -7799,6 +8076,7 @@ F28EDDF9277242505FDDECA1 /* CustomValidatorListProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CustomValidatorListProtocols.swift; sourceTree = ""; }; F2B438707EA6C81C48EAB4CE /* ParaStkYieldBoostStopViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkYieldBoostStopViewController.swift; sourceTree = ""; }; F2B676982F60C55530BDD569 /* AccountManagementPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountManagementPresenter.swift; sourceTree = ""; }; + F2FB715B933FB8E34A553A80 /* SwapConfirmViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapConfirmViewController.swift; sourceTree = ""; }; F31A3D4E3894582CB49013F0 /* ParaStkYieldBoostStartViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParaStkYieldBoostStartViewLayout.swift; sourceTree = ""; }; F32F1F0F6F985195CD19EDDB /* GovernanceEditDelegationTracksProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceEditDelegationTracksProtocols.swift; sourceTree = ""; }; F3526D1FB18C2615BE28E15C /* GovernanceUnavailableTracksViewLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceUnavailableTracksViewLayout.swift; sourceTree = ""; }; @@ -7929,6 +8207,7 @@ F829E7F8B39EE7D977001510 /* ControllerAccountProtocols.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ControllerAccountProtocols.swift; sourceTree = ""; }; F8C16637ADA10892106DD304 /* GovernanceDelegateInfoWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GovernanceDelegateInfoWireframe.swift; sourceTree = ""; }; F8D55623AB063B11C1551012 /* Pods-novawalletAll-novawallet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-novawalletAll-novawallet.debug.xcconfig"; path = "Target Support Files/Pods-novawalletAll-novawallet/Pods-novawalletAll-novawallet.debug.xcconfig"; sourceTree = ""; }; + F9F3BD600F80ED0426141843 /* SwapSlippageViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapSlippageViewController.swift; sourceTree = ""; }; FA3F824117720D3CE65A195F /* LedgerDiscoverPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LedgerDiscoverPresenter.swift; sourceTree = ""; }; FA59CE2C7AE548ACA9D66FD7 /* CrowdloanContributionConfirmWireframe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrowdloanContributionConfirmWireframe.swift; sourceTree = ""; }; FB2EA5D52F51F03FBAB490FE /* ChainAddressDetailsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChainAddressDetailsViewController.swift; sourceTree = ""; }; @@ -8137,6 +8416,56 @@ path = Amount; sourceTree = ""; }; + 0C0CB37C2AC5408000EAC516 /* AssetConversion */ = { + isa = PBXGroup; + children = ( + 0C0CB3802AC5459200EAC516 /* Service */, + 0C0CB37D2AC5408900EAC516 /* Model */, + ); + path = AssetConversion; + sourceTree = ""; + }; + 0C0CB37D2AC5408900EAC516 /* Model */ = { + isa = PBXGroup; + children = ( + 0C0CB37E2AC540B200EAC516 /* AssetConversion.swift */, + ); + path = Model; + sourceTree = ""; + }; + 0C0CB3802AC5459200EAC516 /* Service */ = { + isa = PBXGroup; + children = ( + 0C0CB3832AC561CA00EAC516 /* AssetHub */, + 0C0CB3812AC545A800EAC516 /* AssetConversionExtrinsicService.swift */, + 0CD352962ACAFADA00B3E446 /* AssetConversionOperationFactory.swift */, + 0CD3A67B2AEAA3B90059BBEC /* AssetConversionFeeService.swift */, + 0CEB4ED22AF1689D0048FD84 /* AssetConversionAggregationFactory.swift */, + ); + path = Service; + sourceTree = ""; + }; + 0C0CB3832AC561CA00EAC516 /* AssetHub */ = { + isa = PBXGroup; + children = ( + 0C0CB3842AC561FB00EAC516 /* AssetHubSwapOperationFactory.swift */, + 0C9D87AD2AC708070095FE8C /* AssetHubTokensConverter.swift */, + 0C2200682ACA80BB0067BA61 /* AssetHubSwapQuoteBuilder.swift */, + 0CD352922ACAD7A500B3E446 /* AssetHubExtrinsicService.swift */, + 0CD3A67D2AEAAB670059BBEC /* AssetHubFeeService.swift */, + ); + path = AssetHub; + sourceTree = ""; + }; + 0C0CB3862AC5686C00EAC516 /* AssetConversionPallet */ = { + isa = PBXGroup; + children = ( + 0C0CB3872AC5688100EAC516 /* AssetConversionPallet.swift */, + 0C0CB3892AC56A1600EAC516 /* AssetConversionPallet+Path.swift */, + ); + path = AssetConversionPallet; + sourceTree = ""; + }; 0C13D2F82A7D469E0054BB6F /* Recommendation */ = { isa = PBXGroup; children = ( @@ -8198,6 +8527,15 @@ path = Model; sourceTree = ""; }; + 0C22006C2ACAAC0F0067BA61 /* AssetConversionPallet */ = { + isa = PBXGroup; + children = ( + 0C22006D2ACAAC2F0067BA61 /* AssetConversionPallet+Call.swift */, + 0C500B1E2B04EA9100ABEE70 /* AssetConversionPallet+Event.swift */, + ); + path = AssetConversionPallet; + sourceTree = ""; + }; 0C2F86872A723E4200593C01 /* NominationPools */ = { isa = PBXGroup; children = ( @@ -8217,6 +8555,18 @@ path = NominationPools; sourceTree = ""; }; + 0C2FDF172AEB8292006A6C59 /* Model */ = { + isa = PBXGroup; + children = ( + 0C2FDF182AEB82AA006A6C59 /* AssetHubFeeModelBuilder.swift */, + 0C13DFCC2AF8A5A300E5F355 /* SwapMaxModel.swift */, + 0C13DFDA2AFA691400E5F355 /* SlippageConfig.swift */, + 0C13DFE02AFBBAF600E5F355 /* SwapBaseViewModelFactory.swift */, + 0C9A7F982AFC9A2B00938CD0 /* SwapPriceDifferenceConfig.swift */, + ); + path = Model; + sourceTree = ""; + }; 0C3205BC2A867A46002EB914 /* GasPriceProviders */ = { isa = PBXGroup; children = ( @@ -8291,6 +8641,29 @@ path = Effects; sourceTree = ""; }; + 0C500B202B05101100ABEE70 /* AssetTxPaymentPallet */ = { + isa = PBXGroup; + children = ( + 0C500B212B05102900ABEE70 /* AssetTxPaymentPallet.swift */, + ); + path = AssetTxPaymentPallet; + sourceTree = ""; + }; + 0C500B252B0730FC00ABEE70 /* TransactionSubscription */ = { + isa = PBXGroup; + children = ( + 8454C26E2632BBAA00657DAD /* ExtrinsicProcessing.swift */, + 849B563227A70D71007D5528 /* ExtrinsicProcessor+Fee.swift */, + 849B563427A70DDE007D5528 /* ExtrinsicProcessor+Matching.swift */, + 847A25BA28D7BB92006AC9F5 /* ExtrinsicProcessor+Events.swift */, + 84FD3DB62540EF0700A234E3 /* TransactionSubscription.swift */, + 0CA7821B2B03D0A9003F562A /* ExtrinsicProcessor+SwapMatching.swift */, + 0C500B232B0511F400ABEE70 /* ExtrinsicProcessor+CustomFee.swift */, + 0C500B262B07314000ABEE70 /* ExtrinsicProcessingResult.swift */, + ); + path = TransactionSubscription; + sourceTree = ""; + }; 0C59E8CA2AA5D621001E11F3 /* ExternalBalanceUpdater */ = { isa = PBXGroup; children = ( @@ -8333,6 +8706,7 @@ children = ( 0C59E8EF2AA76361001E11F3 /* OperationDetailsDataProviderProtocol.swift */, 0C59E8F12AA76436001E11F3 /* OperationDetailsTransferProvider.swift */, + 77AAE2212AFB026E006872CC /* OperationDetailsSwapProvider.swift */, 0C59E8F32AA7649E001E11F3 /* OperationDetailsBaseProvider.swift */, 0C59E8F52AA76772001E11F3 /* OperationDetailsExtrinsicProvider.swift */, 0C59E8F72AA76833001E11F3 /* OperationDetailsContractProvider.swift */, @@ -8464,6 +8838,20 @@ path = ViewModel; sourceTree = ""; }; + 0CA50CAD2AFE602F005668CD /* GetTokenOptions */ = { + isa = PBXGroup; + children = ( + 0CB64E582AFE9939008F268F /* Model */, + 0CA50CAE2AFE6094005668CD /* GetTokenOptionsViewController.swift */, + 0CA50CB02AFE6F15005668CD /* GetTokenOptionsProtocols.swift */, + 0CA50CB22AFE6F23005668CD /* GetTokenOptionsPresenter.swift */, + 0CA50CB42AFE6F31005668CD /* GetTokenOptionsInteractor.swift */, + 0CA50CB62AFE6F40005668CD /* GetTokenOptionsViewFactory.swift */, + 0CB64E5F2B00AD83008F268F /* GetTokenOptionsWireframe.swift */, + ); + path = GetTokenOptions; + sourceTree = ""; + }; 0CB261D52A97A4D300287305 /* NominationPools */ = { isa = PBXGroup; children = ( @@ -8534,6 +8922,43 @@ path = Model; sourceTree = ""; }; + 0CB64E582AFE9939008F268F /* Model */ = { + isa = PBXGroup; + children = ( + 0CB64E592AFE9947008F268F /* GetTokenOperation.swift */, + 0CB64E5B2B009DA9008F268F /* GetTokenOptionsModel.swift */, + 0CB64E5D2B00AA8F008F268F /* GetTokenOptionsResult.swift */, + ); + path = Model; + sourceTree = ""; + }; + 0CB64E632B01E0AB008F268F /* View */ = { + isa = PBXGroup; + children = ( + 0CB64E642B01E0CC008F268F /* TransferNetworkSelectionCell.swift */, + 0CB64E662B01E174008F268F /* TransferNetworkSelectionViewModel.swift */, + ); + path = View; + sourceTree = ""; + }; + 0CCA24592AC6914100AEF23D /* V3 */ = { + isa = PBXGroup; + children = ( + 0CCA245A2AC6917400AEF23D /* XcmV3.swift */, + 0CCA245C2AC6918800AEF23D /* XcmV3Junction.swift */, + 0CCA245E2AC6974200AEF23D /* XcmV3Multilocation.swift */, + ); + path = V3; + sourceTree = ""; + }; + 0CD352992ACD3E3500B3E446 /* Assets */ = { + isa = PBXGroup; + children = ( + 0CD3529A2ACD3E4300B3E446 /* PalletAssets+Call.swift */, + ); + path = Assets; + sourceTree = ""; + }; 0CE550B42A4973BA00F0A7AC /* StakingUnbondSetup */ = { isa = PBXGroup; children = ( @@ -8542,6 +8967,33 @@ path = StakingUnbondSetup; sourceTree = ""; }; + 0CFA16142B0CD8A9007AF885 /* Parser */ = { + isa = PBXGroup; + children = ( + 0CFA16122B0CD8A0007AF885 /* GovSpentAmountExtractor.swift */, + 0CFA16152B0CE51E007AF885 /* GovSpentAmountBatchHandler.swift */, + 0CFA161D2B0CED07007AF885 /* GovTreasurySpentAmountHandler.swift */, + 0CFA161F2B0CEF31007AF885 /* GovTreasuryApproveHandler.swift */, + ); + path = Parser; + sourceTree = ""; + }; + 0CFA16172B0CE6F6007AF885 /* UtilityPallet */ = { + isa = PBXGroup; + children = ( + 0CFA16182B0CE709007AF885 /* UtilityPallet.swift */, + ); + path = UtilityPallet; + sourceTree = ""; + }; + 0CFA161A2B0CE83A007AF885 /* UtilityPallet */ = { + isa = PBXGroup; + children = ( + 0CFA161B2B0CE851007AF885 /* Utility+Calls.swift */, + ); + path = UtilityPallet; + sourceTree = ""; + }; 0D927CDE29F5C9F4CA537F8F /* AccountConfirm */ = { isa = PBXGroup; children = ( @@ -8804,6 +9256,38 @@ path = AccountManagement; sourceTree = ""; }; + 288677D19FEB54E369E6B619 /* Slippage */ = { + isa = PBXGroup; + children = ( + 7752E16B2AD878A4006E2F92 /* Model */, + 2AE0E677E64DAC7E93562412 /* SwapSlippageProtocols.swift */, + 088C765E5A0F81B96ADE72D8 /* SwapSlippageWireframe.swift */, + 0816F2A4A5CC1F111E626188 /* SwapSlippagePresenter.swift */, + F9F3BD600F80ED0426141843 /* SwapSlippageViewController.swift */, + 2E965356C7C646CB86BBEBB6 /* SwapSlippageViewLayout.swift */, + 28294C13CF8F62D2FE4D0427 /* SwapSlippageViewFactory.swift */, + 0C13DFDC2AFA82BA00E5F355 /* AmountInputViewModel+Slippage.swift */, + ); + path = Slippage; + sourceTree = ""; + }; + 29BD7DA0076BA8BC3411221A /* Setup */ = { + isa = PBXGroup; + children = ( + 0CA50CAD2AFE602F005668CD /* GetTokenOptions */, + 77E304B32AEFC2E2006FD6F0 /* NetworkFeeBottomSheet */, + 77C9BCBA2ACD1AE800022EA2 /* Model */, + 774091FA2ACC052400172516 /* View */, + 53775773F2060B4B7F6D62DA /* SwapSetupProtocols.swift */, + C7F910E56C2CC7AA5224BD21 /* SwapSetupWireframe.swift */, + BB494F0B16C9588325CF0D84 /* SwapSetupPresenter.swift */, + C585109AC3A2580AB1253C31 /* SwapSetupInteractor.swift */, + BBEB2BC52266AF493D310834 /* SwapSetupViewController.swift */, + 6A6BCE8F8DB7576E1E5B5974 /* SwapSetupViewFactory.swift */, + ); + path = Setup; + sourceTree = ""; + }; 2A1FB6759F2E8A05A1894287 /* GovernanceUnavailableTracks */ = { isa = PBXGroup; children = ( @@ -9348,6 +9832,71 @@ path = Model; sourceTree = ""; }; + 7719018A2AE0E62500D9C918 /* Validation */ = { + isa = PBXGroup; + children = ( + 7719018B2AE0E70B00D9C918 /* SwapDataValidatorFactory.swift */, + 7719018D2AE0E71F00D9C918 /* SwapErrorPresentable.swift */, + 0C13DFC82AF4FFC200E5F355 /* SwapErrorPresentableParams.swift */, + 0C13DFCA2AF6182500E5F355 /* SwapModel.swift */, + ); + path = Validation; + sourceTree = ""; + }; + 771901912AE2425400D9C918 /* Swaps */ = { + isa = PBXGroup; + children = ( + 7719018F2AE2424B00D9C918 /* SwapsValidationTests.swift */, + ); + path = Swaps; + sourceTree = ""; + }; + 771901A02AE7E33A00D9C918 /* Base */ = { + isa = PBXGroup; + children = ( + 0C2FDF172AEB8292006A6C59 /* Model */, + 771901A92AE8FFDC00D9C918 /* View */, + 771901A12AE7E34D00D9C918 /* SwapBaseInteractor.swift */, + 771901A32AE7E48800D9C918 /* SwapBaseProtocols.swift */, + 771901B12AEA3E5100D9C918 /* ShortTextInfoPresentableExtensions.swift */, + 77E304AA2AEBB214006FD6F0 /* SlippageBounds.swift */, + 77E304AC2AEC16B2006FD6F0 /* SwapPriceDifferenceViewModelFactoryProtocol.swift */, + 0C13DFD02AF8AE3E00E5F355 /* SwapBasePresenter.swift */, + ); + path = Base; + sourceTree = ""; + }; + 771901A92AE8FFDC00D9C918 /* View */ = { + isa = PBXGroup; + children = ( + 771901A72AE8FF9F00D9C918 /* SwapNetworkFeeViewCell.swift */, + 771901A52AE8FF7E00D9C918 /* SwapInfoViewCell.swift */, + 77740BBF2AD4A80D00E8C06F /* SwapInfoView.swift */, + 77ECB46F2ACEEE2D0015CE9F /* SwapNetworkFeeView.swift */, + ); + path = View; + sourceTree = ""; + }; + 771901AC2AE9730800D9C918 /* View */ = { + isa = PBXGroup; + children = ( + 382B8763E4FDA7C4E2EF06A8 /* SwapConfirmViewLayout.swift */, + 7719019C2AE6996600D9C918 /* SwapPairView.swift */, + 7719019E2AE6C9DC00D9C918 /* SwapElementView.swift */, + ); + path = View; + sourceTree = ""; + }; + 771901AE2AE9733200D9C918 /* Model */ = { + isa = PBXGroup; + children = ( + 771901AA2AE9581400D9C918 /* SwapConfirmViewModelFactory.swift */, + 771901AF2AE97DA500D9C918 /* SwapConfirmViewModels.swift */, + 77E304A82AEB9F76006FD6F0 /* SwapConfirmInitState.swift */, + ); + path = Model; + sourceTree = ""; + }; 7726E232D196BDD627329E24 /* ParaStkStakeSetup */ = { isa = PBXGroup; children = ( @@ -9384,6 +9933,28 @@ path = RelaychainStaking; sourceTree = ""; }; + 774091FA2ACC052400172516 /* View */ = { + isa = PBXGroup; + children = ( + 774091F82ACB1F4B00172516 /* SwapAmountInputView.swift */, + 774091FB2ACC053000172516 /* SwapAssetView.swift */, + 774091FD2ACC054B00172516 /* SwapAssetControl.swift */, + 774091FF2ACC1BE400172516 /* SwapAmountInput.swift */, + CDD43373A9159E0A592077BB /* SwapSetupViewLayout.swift */, + 77740BBD2AD4A7F500E8C06F /* SwapDetailsView.swift */, + 77740BC12AD69E3400E8C06F /* SwapMaxButtonView.swift */, + ); + path = View; + sourceTree = ""; + }; + 7752E16B2AD878A4006E2F92 /* Model */ = { + isa = PBXGroup; + children = ( + 77740BC52AD849D100E8C06F /* SlippagePercentViewModel.swift */, + ); + path = Model; + sourceTree = ""; + }; 775692822A24CA5100220756 /* AssetOperation */ = { isa = PBXGroup; children = ( @@ -9537,6 +10108,44 @@ path = canonicalization; sourceTree = ""; }; + 77C9BCBA2ACD1AE800022EA2 /* Model */ = { + isa = PBXGroup; + children = ( + 77C976232AF3A5280049272C /* SwapViewModels.swift */, + 77C9761F2AF36A170049272C /* SwapModels.swift */, + 77C9BCBD2ACD286100022EA2 /* SwapsSetupViewModelFactory.swift */, + 0C13DFD42AFA4F1500E5F355 /* SwapIssueCheckParams.swift */, + 0C13DFD62AFA50A200E5F355 /* SwapIssueViewModelFactory.swift */, + ); + path = Model; + sourceTree = ""; + }; + 77C9BCBF2ACD2E0300022EA2 /* Swaps */ = { + isa = PBXGroup; + children = ( + 7719018A2AE0E62500D9C918 /* Validation */, + 288677D19FEB54E369E6B619 /* Slippage */, + 771901A02AE7E33A00D9C918 /* Base */, + 29BD7DA0076BA8BC3411221A /* Setup */, + 7E5E800395DC908962C169CF /* Confirm */, + ); + path = Swaps; + sourceTree = ""; + }; + 77C9BCC22ACD563E00022EA2 /* Swaps */ = { + isa = PBXGroup; + children = ( + 77C9BCC32ACD570100022EA2 /* SwapAssetsOperationInteractor.swift */, + 77C9BCC52ACD571400022EA2 /* SwapAssetOperationPresenter.swift */, + 77C9BCC92ACD574800022EA2 /* SwapAssetOperationWireframe.swift */, + 77C9BCCB2ACD576B00022EA2 /* SwapAssetsOperationProtocols.swift */, + 77C9BCCD2ACD691300022EA2 /* SwapAssetsOperationViewFactory.swift */, + 77C9BCCF2ACD8A3600022EA2 /* SwapAssetsOperationViewLayout.swift */, + 77C9BCD12ACD8AF400022EA2 /* SwapAssetsOperationViewController.swift */, + ); + path = Swaps; + sourceTree = ""; + }; 77CB33D32A38894600B6709A /* Integrity */ = { isa = PBXGroup; children = ( @@ -9565,6 +10174,17 @@ path = SelectValidatorsConfirm; sourceTree = ""; }; + 77E304B32AEFC2E2006FD6F0 /* NetworkFeeBottomSheet */ = { + isa = PBXGroup; + children = ( + 77E304AF2AEFA261006FD6F0 /* SwapNetworkFeeSheetLayout.swift */, + 77E304B12AEFC0F3006FD6F0 /* SwapNetworkFeeSheetViewController.swift */, + 77E304B42AEFC303006FD6F0 /* SwapNetworkFeeSheetViewFactory.swift */, + 77E304B62AEFC348006FD6F0 /* SwapNetworkFeeSheetViewModel.swift */, + ); + path = NetworkFeeBottomSheet; + sourceTree = ""; + }; 77E4088EF503B8FD414F14EA /* GovernanceUnlockConfirm */ = { isa = PBXGroup; children = ( @@ -9758,6 +10378,21 @@ path = ParaStkRebond; sourceTree = ""; }; + 7E5E800395DC908962C169CF /* Confirm */ = { + isa = PBXGroup; + children = ( + 771901AE2AE9733200D9C918 /* Model */, + 771901AC2AE9730800D9C918 /* View */, + 796859464B823B60746C5DE5 /* SwapConfirmProtocols.swift */, + 7CD1FBC9C063951E2520265D /* SwapConfirmWireframe.swift */, + 48E6BE303472080271AAC917 /* SwapConfirmPresenter.swift */, + 2DEED7526468089FE8A8989C /* SwapConfirmInteractor.swift */, + F2FB715B933FB8E34A553A80 /* SwapConfirmViewController.swift */, + AF5DEDDC53639DFCF524D794 /* SwapConfirmViewFactory.swift */, + ); + path = Confirm; + sourceTree = ""; + }; 7F067CADB4CD05233424BD6D /* SessionDetails */ = { isa = PBXGroup; children = ( @@ -9991,6 +10626,7 @@ 84155DE8253980D700A27058 /* Services */ = { isa = PBXGroup; children = ( + 0C500B252B0730FC00ABEE70 /* TransactionSubscription */, 0C59E8CA2AA5D621001E11F3 /* ExternalBalanceUpdater */, 8455F1912A1DC631003F072D /* Multistaking */, 8490111229E68FAD005D688B /* WalletConnect */, @@ -10116,6 +10752,7 @@ 8422F2ED2887E3D300C7B840 /* TextInputView.swift */, 847999B72889510C00D1BAD2 /* TextInputViewDelegate.swift */, 849F1452294477DA00D9F9BA /* TextWithServiceInputView.swift */, + 77740BC32AD8145500E8C06F /* PercentInputView.swift */, ); path = TextInputView; sourceTree = ""; @@ -10295,6 +10932,7 @@ 842A736327DB31A3006EE1EA /* OperationRewardOrSlashModel.swift */, 77AB55582AA244BB0058814E /* OperationPoolRewardOrSlashModel.swift */, 842A736727DB4883006EE1EA /* OperationTransferModel.swift */, + 77AAE21F2AFB00CB006872CC /* OperationSwapModel.swift */, 84E0C51D29CA40DA000B65C8 /* OperationContractCallModel.swift */, ); path = Model; @@ -10305,6 +10943,7 @@ children = ( 842A736A27DB7A2E006EE1EA /* OperationDetailsViewModel.swift */, 842A736C27DB7B5E006EE1EA /* OperationTransferViewModel.swift */, + 77C976272AF426100049272C /* OperationSwapViewModel.swift */, 842A736E27DB7E57006EE1EA /* OperationExtrinsicViewModel.swift */, 842A737227DB7F75006EE1EA /* OperationRewardOrSlashViewModel.swift */, 77AB555A2AA246CA0058814E /* OperationPoolRewardOrSlashViewModel.swift */, @@ -10318,6 +10957,7 @@ isa = PBXGroup; children = ( 842A737B27DCC488006EE1EA /* OperationDetailsTransferView.swift */, + 77C976252AF421AE0049272C /* OperationDetailsSwapView.swift */, 842A737D27DCD1A0006EE1EA /* OperationDetailsExtrinsicView.swift */, 842A737F27DCD427006EE1EA /* OperationDetailsRewardView.swift */, 77AB555C2AA24BA90058814E /* OperationDetailsPoolRewardView.swift */, @@ -10752,6 +11392,7 @@ 0C2F86992A72948100593C01 /* NominationPoolsApyTests.swift */, 0C3205C12A868236002EB914 /* EvmGasPriceIntegrationTests.swift */, 0C59E8EA2AA71C3E001E11F3 /* ExternalAssetBalanceIntegrationTests.swift */, + 0CCA24642AC6B51200AEF23D /* AssetHubSwapTests.swift */, ); path = novawalletIntegrationTests; sourceTree = ""; @@ -10759,6 +11400,9 @@ 8438E1DC24C18F11001BDB13 /* Types */ = { isa = PBXGroup; children = ( + 0CFA16172B0CE6F6007AF885 /* UtilityPallet */, + 0C500B202B05101100ABEE70 /* AssetTxPaymentPallet */, + 0C0CB3862AC5686C00EAC516 /* AssetConversionPallet */, 0C7945B92ABB223D001C07CA /* XTokens */, 0C893E6B2A65629E00781503 /* NominationPools */, 8498534D2A1738EC00993977 /* Assets */, @@ -11387,6 +12031,7 @@ 845B08022918C2E5005785D3 /* Action */ = { isa = PBXGroup; children = ( + 0CFA16142B0CD8A9007AF885 /* Parser */, 845B811E28F451A40040CE84 /* GovernanceActionOperationFactory.swift */, 845B08002918406A005785D3 /* Gov2ActionOperationFactory.swift */, 845B08032918C308005785D3 /* Gov1ActionOperationFactory.swift */, @@ -11479,6 +12124,9 @@ 845BB8C725E45D0600E5FCDC /* Calls */ = { isa = PBXGroup; children = ( + 0CFA161A2B0CE83A007AF885 /* UtilityPallet */, + 0CD352992ACD3E3500B3E446 /* Assets */, + 0C22006C2ACAAC0F0067BA61 /* AssetConversionPallet */, 0C7945BC2ABB22AA001C07CA /* XTokens */, 0C13D3052A7FB9170054BB6F /* NominationPools */, 843ADAC12A38FC4C003AE2B5 /* Staking */, @@ -12024,14 +12672,9 @@ 84F30EA025FD3EE700039D09 /* ChildSubscriptionFactory.swift */, 84F30E9B25FD3DBC00039D09 /* EmptyHandlingStorageSubscription.swift */, 84F30E9625FD3C5300039D09 /* EventEmittingStorageSubscription.swift */, - 8454C26E2632BBAA00657DAD /* ExtrinsicProcessing.swift */, - 849B563227A70D71007D5528 /* ExtrinsicProcessor+Fee.swift */, - 849B563427A70DDE007D5528 /* ExtrinsicProcessor+Matching.swift */, - 847A25BA28D7BB92006AC9F5 /* ExtrinsicProcessor+Events.swift */, 8470D6D1253E3382009E9A5D /* StorageSubscriptionContainer.swift */, 8470D6CF253E321C009E9A5D /* StorageSubscriptionProtocols.swift */, 8470D6D3253E35F0009E9A5D /* StorageUpdate.swift */, - 84FD3DB62540EF0700A234E3 /* TransactionSubscription.swift */, 84B73AD1279B47700071AE16 /* AssetsBalanceUpdater.swift */, 84B73AD7279C2EDA0071AE16 /* AssetAccountSubscription.swift */, 84F18D4D27A18C1400CA7554 /* OrmlAccountSubscription.swift */, @@ -12311,6 +12954,7 @@ 0C83775C2A4EEB380072102D /* AssetListState.swift */, 0CAC01542A52E0CC0069413E /* AssetListModelHelpers.swift */, 77CBD39B2ABBA98900D646D6 /* AssetListModel.swift */, + 77D2E2702B05416E0098F188 /* AssetListGroupModelCompator.swift */, ); path = Models; sourceTree = ""; @@ -12549,12 +13193,13 @@ isa = PBXGroup; children = ( 848F8B212863BD1000204BC4 /* TransferSetupInputState.swift */, - 842B17FE28649CCD0014CC57 /* CrossChainDestinationSelectionState.swift */, + 842B17FE28649CCD0014CC57 /* CrossChainSelectionState.swift */, 84B28FC328C54441007A1006 /* OnChainTransferAmount.swift */, 88F33F1229CC1ECD006125D5 /* Web3NameAddressesSelectionState.swift */, 8863C7AF29D49CB70068AD54 /* Web3NameViewModelFactory.swift */, 8846F71D29D5675E00B8B776 /* Web3NameRecipientListViewModel.swift */, 88840D8929DEA975002EFFFD /* TransferSetupRecipientAccount.swift */, + 0CB64E612B012E92008F268F /* TransferSetupPeer.swift */, ); path = Model; sourceTree = ""; @@ -12735,6 +13380,7 @@ 849013D224A9268D008F705E /* Modules */ = { isa = PBXGroup; children = ( + 0C0CB37C2AC5408000EAC516 /* AssetConversion */, 88C5F079297EE429001CCADE /* InAppUpdates */, 845B891A2959608D00EE25B0 /* SecurityLayer */, ABAF6F503B172CEE34E19030 /* MarkdownDescription */, @@ -12762,9 +13408,9 @@ 8490140124A92F6D008F705E /* OnbordingMain */, 849014A724AA87E3008F705E /* Pincode */, 8428764224ADDE0200D91AD8 /* Settings */, - F1A9198B13888515D787A6C1 /* Purchase */, 8490146E24A94A37008F705E /* Root */, 84F43B8725DE9F8500AEDA56 /* Staking */, + F1A9198B13888515D787A6C1 /* Purchase */, 84E1CCF3260DC973001E81B5 /* SwitchAccount */, C7AEDB8341B78EC46F6F98DC /* UsernameSetup */, A800B16846A754FEDAF801EC /* AssetSelection */, @@ -12780,6 +13426,7 @@ 9D97DD4BC9672502D2E2A625 /* TokensManage */, EC1A579A3747EB16688DAEBF /* AssetReceive */, C9850B4B70AEFEABB96269FF /* TransactionHistory */, + 77C9BCBF2ACD2E0300022EA2 /* Swaps */, ); path = Modules; sourceTree = ""; @@ -12899,6 +13546,8 @@ 8455F1A32A1F606B003F072D /* OnchainStorage.swift */, 0C3205EB2A8A122D002EB914 /* FeeOutputModel.swift */, 0C59E8D02AA5FAC5001E11F3 /* PooledAssetBalance.swift */, + 0CD352942ACAF59900B3E446 /* BigRational.swift */, + 77D2E2722B0542A50098F188 /* AmountPair.swift */, ); path = Model; sourceTree = ""; @@ -13006,6 +13655,7 @@ 0CC2E5692A6E6EBB004092E7 /* LocalStorageProviderObserving.swift */, 772540352AC45CDB002B3FD4 /* PurchaseFlowManaging.swift */, 772540372AC45D22002B3FD4 /* PurchasePresentable.swift */, + 7719019A2AE670AE00D9C918 /* ShortTextInfoPresentable.swift */, ); path = Protocols; sourceTree = ""; @@ -13147,6 +13797,8 @@ 0C9525E62A7AFA2C00BD724D /* ValueResolver.swift */, 0CF193D42A861925003F12F6 /* PredefinedTimeShortcut.swift */, 0CBF5DE62AB1A60500087EBF /* SharedOperationStatus.swift */, + 0CEB4ED02AF14B8B0048FD84 /* SubmoduleNavigationStrategy.swift */, + 0CEB4ED42AF20EB90048FD84 /* CancellableCallHelper.swift */, ); path = Helpers; sourceTree = ""; @@ -13191,6 +13843,8 @@ 77E0DC9D2A6940C400D03724 /* Calendar+Helpers.swift */, 0C7C9B982ABFF355009A0362 /* String+Html.swift */, 0C7C9B9A2AC16D7B009A0362 /* NSAttributedString+Helpers.swift */, + 0C13DFCE2AF8ADB300E5F355 /* BigUInt+Operation.swift */, + 0C13DFDE2AFA89EF00E5F355 /* Decimal+Percents.swift */, ); path = Foundation; sourceTree = ""; @@ -13199,6 +13853,7 @@ isa = PBXGroup; children = ( 8490146C24A9487A008F705E /* ErrorPresentable+AlertText.swift */, + 77A4F4022B036615006294BC /* Optional+Result.swift */, ); path = Error; sourceTree = ""; @@ -13369,6 +14024,7 @@ 7796C7022A17846B00D56094 /* EmptyCellContentView.swift */, 842D8B772A4098C300660005 /* ShimmeringLabel.swift */, 88A95FA728FAA99D00BE26F3 /* DAppView.swift */, + 77740BBB2AD4A7B800E8C06F /* CollapsableContainerView.swift */, ); path = View; sourceTree = ""; @@ -13551,6 +14207,8 @@ 0C1338112AB8330D0036BCD6 /* AnimatedImageViewModel.swift */, 0C1338132AB834750036BCD6 /* QRImageViewModelFactory.swift */, 0C9951D22AE2DB0200B65615 /* PromotionViewModelFactory.swift */, + 8828F4F428AD2763009E0B7C /* BalanceViewModelFactoryFacade.swift */, + 0C13DFD22AF949B400E5F355 /* BalanceViewModelFactoryFacade+Formatting.swift */, ); path = ViewModel; sourceTree = ""; @@ -13849,6 +14507,8 @@ children = ( 84FD3DB02540C09800A234E3 /* TransactionHistoryMergeManager.swift */, 849B036F2A15EE39009624D9 /* TokenPriceCalculator.swift */, + 77AAE2252AFC10EE006872CC /* PriceHistoryCalculatorFactory.swift */, + 77AAE2272AFC1167006872CC /* ChainModel+historyId.swift */, ); path = TransactionHistory; sourceTree = ""; @@ -13960,6 +14620,7 @@ 843910C6253F56EA00E3C217 /* BaseOperation+Result.swift */, 84BB3CFF267D364D00676FFE /* CompoundOperationWrapper+Dependency.swift */, 849C0670276516F900394C82 /* BaseOperation+Cancellable.swift */, + 0CD3A67F2AEAC3C90059BBEC /* CompoundOperationWrapper+Add.swift */, ); path = Operation; sourceTree = ""; @@ -14243,6 +14904,7 @@ 84B7C680289BFA78001A3566 /* Modules */ = { isa = PBXGroup; children = ( + 771901912AE2425400D9C918 /* Swaps */, 8440F4A3295AB4C200CAFBF9 /* SecurityLayer */, 843461F8290E55BE00379936 /* Governance */, 84B7C683289BFA78001A3566 /* Settings */, @@ -15513,6 +16175,7 @@ 8436B6D728480D2F00F24360 /* ModalPickerActionTableViewCell.swift */, 842B17FC2864980B0014CC57 /* NetworkSelectionTableViewCell.swift */, 8489198D2A0529DA008D57A3 /* NetworkTableViewCell.swift */, + 77C976212AF39F180049272C /* TokenOperationTableViewCell.swift */, ); path = Cell; sourceTree = ""; @@ -15670,6 +16333,7 @@ isa = PBXGroup; children = ( 84DEE7032797FBF800B9A39E /* ExtrinsicExtension.swift */, + 0CEB4ED82AF371EF0048FD84 /* AssetConversionTxPayment.swift */, ); path = Extension; sourceTree = ""; @@ -15702,6 +16366,7 @@ 8466780D27EB28FF007935D3 /* BaseTransfer */, DA7D18D3AF772CC2385C228C /* TransferSetup */, 34FF81AA0EBFAB3390FD989D /* TransferConfirm */, + BCA8C0E50D704DA8031D1648 /* TransferNetworkSelection */, ); path = Transfer; sourceTree = ""; @@ -16181,12 +16846,12 @@ 84FB9E18285C57C000B42FC0 /* Xcm */ = { isa = PBXGroup; children = ( + 0CCA24592AC6914100AEF23D /* V3 */, 84EBFCE5285E7A6D0006327E /* Message */, 84FB9E19285C57D900B42FC0 /* XcmVersionedMultilocation.swift */, 84FB9E1B285C57EF00B42FC0 /* XcmMultilocation.swift */, 84FB9E1D285C58FF00B42FC0 /* Xcm.swift */, 84FB9E1F285C5C9E00B42FC0 /* XcmJunction.swift */, - 84FB9E21285C5D6200B42FC0 /* XcmNetworkId.swift */, 84FB9E23285C6ACC00B42FC0 /* XcmMultiasset.swift */, 84FB9E25285C6C5000B42FC0 /* XcmVersionedMultiasset.swift */, 84EBFCEF285E84C30006327E /* XcmMultiassetFilter.swift */, @@ -16724,7 +17389,6 @@ E4E78D69E8EBC3EB4D01F8EF /* CrowdloanListInteractor.swift */, 84B66A0A26FDB70F0038B963 /* CrowdloansListInteractor+Protocols.swift */, 8828F4F228AD2734009E0B7C /* CrowdloansCalculator.swift */, - 8828F4F428AD2763009E0B7C /* BalanceViewModelFactoryFacade.swift */, 8442001F28E6FDBE00C49C4A /* CrowdloanListViewManager.swift */, ); path = CrowdloanList; @@ -16795,6 +17459,7 @@ 88D02FED2942F00D00E26390 /* AssetDetailsOperation.swift */, 88D02FF129431FC900E26390 /* AssetDetailsLocksViewModel.swift */, 846AF8452525C93A00868F37 /* BalanceContext.swift */, + 77A4F4002B035027006294BC /* AssetOperationState.swift */, ); path = Model; sourceTree = ""; @@ -16991,6 +17656,8 @@ 1DC6917929E4A752B79FE554 /* OperationDetailsWireframe.swift */, 3D2A26EC9537BD4275A03272 /* OperationDetailsPresenter.swift */, 661356CFE77B978610397907 /* OperationDetailsInteractor.swift */, + 77AAE2232AFB67BE006872CC /* OperationSwapDetailsInteractor.swift */, + 77AAE2292AFC36EE006872CC /* OperationDetailsBaseInteractor.swift */, 9BCCD837A377C237C18B117E /* OperationDetailsViewController.swift */, 73D569738955713647612599 /* OperationDetailsViewLayout.swift */, A5F05632A6635A54A9CDA7FC /* OperationDetailsViewFactory.swift */, @@ -17027,6 +17694,7 @@ A29C55960FE9EADBDEAC6F03 /* AssetsSearch */ = { isa = PBXGroup; children = ( + 77C9BCC22ACD563E00022EA2 /* Swaps */, 0C1FE4F22A52EDD5003769E7 /* Model */, 775692822A24CA5100220756 /* AssetOperation */, 778D97A02A24D459002BA681 /* AssetSearch */, @@ -17583,7 +18251,6 @@ AEFC6D602600A754000BD310 /* View */ = { isa = PBXGroup; children = ( - 849528E226036997009DC845 /* RewardEstimationView.swift */, 84FFE504261290830054EA63 /* NetworkInfoView.swift */, F418E890264D318C00699085 /* AlertsView.swift */, 84B018AB26E01A4100C75E28 /* StakingStateView.swift */, @@ -17717,6 +18384,19 @@ path = AssetDetails; sourceTree = ""; }; + BCA8C0E50D704DA8031D1648 /* TransferNetworkSelection */ = { + isa = PBXGroup; + children = ( + 0CB64E632B01E0AB008F268F /* View */, + 850BB5254D6443ACFDBB96CC /* TransferNetworkSelectionProtocols.swift */, + CAE1DE626A1960428C9AEC52 /* TransferNetworkSelectionPresenter.swift */, + 486BF5FBE285713D05CF95A8 /* TransferNetworkSelectionInteractor.swift */, + 9D6106D11494F979A46F7B4C /* TransferNetworkSelectionViewController.swift */, + 9CA463769D0F411429780D7D /* TransferNetworkSelectionViewFactory.swift */, + ); + path = TransferNetworkSelection; + sourceTree = ""; + }; BF6F50DD15230CADAC713359 /* AccountImport */ = { isa = PBXGroup; children = ( @@ -17976,6 +18656,7 @@ 848F8B1A28635A6D00204BC4 /* TransferSetupInteractor.swift */, 848F8B1E2863BB4000204BC4 /* TransferSetupPresenterFactory.swift */, 848F8B282864503A00204BC4 /* TransferSetupWireframe.swift */, + 0CB64E682B01F798008F268F /* TransferSetupOriginSelectionWireframe.swift */, ); path = TransferSetup; sourceTree = ""; @@ -19040,8 +19721,10 @@ 849E16E42714D1BD0065B305 /* EthereumBaseIntegrationTests.swift in Sources */, 84BB3CE9267C9ADE00676FFE /* CrowdloanTests.swift in Sources */, 8479F31426CD9A0E005D8D24 /* ChainRegistryIntegrationTests.swift in Sources */, + 0CD352982ACB01FD00B3E446 /* AccountGenerator.swift in Sources */, 844304672A28F7A500DE36DE /* MultistakingSyncTests.swift in Sources */, 84E8AC7727BBC8E400402635 /* NFTIntegrationTests.swift in Sources */, + 0CCA24652AC6B51200AEF23D /* AssetHubSwapTests.swift in Sources */, 849ABE8A262833C000011A2A /* PayoutRewardsServiceTests.swift in Sources */, 8461CC8526BC1306007460E4 /* MortalEraFactoryTests.swift in Sources */, 840874E02978882700ACFA55 /* Gov2DelegationTests.swift in Sources */, @@ -19102,6 +19785,7 @@ 88D02FE52942EA7800E26390 /* AssetDetailsStyles.swift in Sources */, 84CAC1CC298A715900F78169 /* GovernanceOffchainVotesLocal.swift in Sources */, 84F2CEFC29A36CE9003EBE36 /* WalletNoAccountHandling.swift in Sources */, + 0CA50CB32AFE6F23005668CD /* GetTokenOptionsPresenter.swift in Sources */, 774A481129F8BFB70094635B /* OperationAuthPresentable.swift in Sources */, 849014C324AA87E4008F705E /* LocalAuthInteractor.swift in Sources */, 84BEE059255F4D5700D05EB3 /* AccountImportPreferredInfo.swift in Sources */, @@ -19177,8 +19861,10 @@ 842876A424AE049B00D91AD8 /* SelectableSubtitleListViewModel.swift in Sources */, AEE5FB1426457A98002B8FDC /* StakingRewardDestSetupInteractor.swift in Sources */, 842D1E7D24D0686D00C30A7A /* TriangularedButton+Inspectable.swift in Sources */, + 0C0CB3882AC5688100EAC516 /* AssetConversionPallet.swift in Sources */, 845D8ED92977F18300F22133 /* GovernanceDelegateStats.swift in Sources */, 84CF00C327F6C1E4004DB322 /* CustomAssetMapper.swift in Sources */, + 0C0CB37F2AC540B200EAC516 /* AssetConversion.swift in Sources */, 880CC0B029E7F194008C7F65 /* EquillibriumLocksUpdater.swift in Sources */, 843910DB253F90C400E3C217 /* NovaWindow.swift in Sources */, 84DD5F4C263DE82C00425ACF /* DataValidationRunner.swift in Sources */, @@ -19189,6 +19875,7 @@ 88421062289BBD1900306F2C /* JsonFileRepository.swift in Sources */, AE8B8835267349C200AB0AA9 /* CustomVlidatorListFilter.swift in Sources */, F4E17FB827216C2F00FE36D3 /* MoonbeamBonusService.swift in Sources */, + 0CFA16202B0CEF31007AF885 /* GovTreasuryApproveHandler.swift in Sources */, 8490141324A92F6D008F705E /* AttributedStringDecorator+Terms.swift in Sources */, 849976CA27B2F9E000B14A6C /* DAppMetamaskAuthorizingState.swift in Sources */, F484FF5F264BA2720015320F /* ControllerAccountConfirmationLayout.swift in Sources */, @@ -19207,6 +19894,7 @@ 84FBED052927B1CA00FBEB83 /* EvmEventParser.swift in Sources */, 84A1742428ED3CF70096F943 /* ReferendumLocal.swift in Sources */, 841AB7922993D01A00A362E8 /* GovernanceDelegateConfirmInteractorError.swift in Sources */, + 774091F92ACB1F4B00172516 /* SwapAmountInputView.swift in Sources */, 7728E5912A1324A2007901E0 /* ReferendumsSearchManager.swift in Sources */, 843C49DB24DF373000B71DDA /* AccountImportRequest.swift in Sources */, 843910C1253F36F300E3C217 /* BaseStorageChildSubscription.swift in Sources */, @@ -19220,6 +19908,7 @@ 848CCB4C2833979700A1FD00 /* ParaStkStateViewModelFactory+Alert.swift in Sources */, 8444407528AA628300446D22 /* LedgerAccount.swift in Sources */, 84C74361251E4B5E009576C6 /* FeeType.swift in Sources */, + 774091FE2ACC054B00172516 /* SwapAssetControl.swift in Sources */, 844DAAE128AD106B008E11DA /* UInt+Serialization.swift in Sources */, 84981EE429D3352600948306 /* TransactionHistoryFetching.swift in Sources */, 8428765224ADDE0200D91AD8 /* SettingsPresenter.swift in Sources */, @@ -19246,6 +19935,7 @@ 84350ACE2845709D0031EF24 /* IdentityAccountView.swift in Sources */, 8489A6D027FD5B9E0040C066 /* StackActionView.swift in Sources */, 778D979F2A24D248002BA681 /* SearchViewProtocol.swift in Sources */, + 0C9A7F992AFC9A2B00938CD0 /* SwapPriceDifferenceConfig.swift in Sources */, 8460E713284ABE22002896E9 /* UnstakeCallWrapper.swift in Sources */, 843612C3278FF0C700DC739E /* FeeRetryable.swift in Sources */, 8849AD6029C3526700F4F7FF /* Caip2.swift in Sources */, @@ -19289,12 +19979,15 @@ 842D1E8624D1A90400C30A7A /* NSPredicate+Validation.swift in Sources */, 8887814028B7AAB700E7290F /* RoundedIconTitleView.swift in Sources */, 8860F3E2289D4FFD00C0BF86 /* SectionProtocol.swift in Sources */, + 0C22006E2ACAAC2F0067BA61 /* AssetConversionPallet+Call.swift in Sources */, F4FDA0F826A57626003D753B /* BabeEraOperationFactory.swift in Sources */, + 0CFA16162B0CE51E007AF885 /* GovSpentAmountBatchHandler.swift in Sources */, 84AE7AB927D3F96300495267 /* RMRKV1Collection.swift in Sources */, 84AE7AAF27D38B1800495267 /* DrawableIconViewModel.swift in Sources */, 849976D027B3AC0100B14A6C /* MetamaskEvent.swift in Sources */, 0CE629DD2AA9B6BF00E250BD /* RewardDestinationViewModel.swift in Sources */, 849976BC27B25A6600B14A6C /* DAppTransportModel.swift in Sources */, + 771901A42AE7E48800D9C918 /* SwapBaseProtocols.swift in Sources */, 84D2F1B32775A3220040C680 /* ExtrinsicJSONProcessor.swift in Sources */, 8428765624ADDE0200D91AD8 /* SettingsCellViewModel.swift in Sources */, 8428229C289BC9D300163031 /* AddAccount+ParitySignerAddressesWireframe.swift in Sources */, @@ -19355,6 +20048,8 @@ 8490146A24A9463B008F705E /* Locale+Localization.swift in Sources */, 84715786291136B100D7D003 /* GovernanceUnlocksViewModel.swift in Sources */, 84DD5F64263DFAB700425ACF /* ErrorConditionViolation.swift in Sources */, + 0C2FDF192AEB82AA006A6C59 /* AssetHubFeeModelBuilder.swift in Sources */, + 0C13DFCD2AF8A5A300E5F355 /* SwapMaxModel.swift in Sources */, 8428768324AE046300D91AD8 /* LanguageSelectionViewController.swift in Sources */, F452D8CA273E58D5008F7295 /* SettingsTableFooterView.swift in Sources */, 8470D6D2253E3382009E9A5D /* StorageSubscriptionContainer.swift in Sources */, @@ -19408,6 +20103,7 @@ 84B018B026E0450F00C75E28 /* ValidatorStateView.swift in Sources */, AEE0C43A272A8B1F009F9AD5 /* AddChainAccount+AccountCreateWireframe.swift in Sources */, 0C7C9B992ABFF355009A0362 /* String+Html.swift in Sources */, + 77740BC62AD849D100E8C06F /* SlippagePercentViewModel.swift in Sources */, 0C7E7FAB2A9F27FB00596628 /* NominationPoolsRedeemCall.swift in Sources */, 84038FF226FFBE1900C73F3F /* JsonLocalSubscriptionHandler.swift in Sources */, 842898D1265A955A002D5D65 /* ImageViewModel.swift in Sources */, @@ -19498,8 +20194,9 @@ 2AD0A19525D3D3EC00312428 /* GitHubOperationFactory.swift in Sources */, 845B07FF2916F529005785D3 /* Gov1SubscriptionFactory.swift in Sources */, 888A3B6528F73DC300E15BD2 /* ReferendumVotingStatusView.swift in Sources */, + 0C13DFE12AFBBAF600E5F355 /* SwapBaseViewModelFactory.swift in Sources */, 843E9B3627C8B915009C143A /* NftFileDownloadService.swift in Sources */, - 842B17FF28649CCD0014CC57 /* CrossChainDestinationSelectionState.swift in Sources */, + 842B17FF28649CCD0014CC57 /* CrossChainSelectionState.swift in Sources */, 8437F7C12924FF6400DB6366 /* EvmSubscriptionMessage.swift in Sources */, 84A3034926A834F900E64382 /* ValidatorInfoViewLayout.swift in Sources */, 84D1ABE027E1CB870073C631 /* TitleHorizontalMultiValueView.swift in Sources */, @@ -19603,10 +20300,12 @@ 848DAF042822B7FE00D56F55 /* ParachainStakingCollatorService+Fetch.swift in Sources */, 8846F72529D6BA4400B8B776 /* Data+base8.swift in Sources */, 840689FC26321F2700A017B1 /* StorageQuery.swift in Sources */, + 77D2E2732B0542A50098F188 /* AmountPair.swift in Sources */, 8423ADD026B2C38600057EDD /* ImportantFlowViewFactory.swift in Sources */, 887AFC8E28BCB314002A0422 /* SelectableIconSubtitleView.swift in Sources */, 849A4EF8279ABBDD00AB6709 /* AssetBalance.swift in Sources */, 88A0E10128A284C800A9C940 /* Observable.swift in Sources */, + 0C9D87AE2AC708070095FE8C /* AssetHubTokensConverter.swift in Sources */, 84B8AA7529F8FD2400347A37 /* DAppInteractionFactory.swift in Sources */, 844EFB65265FD61D0090ACB1 /* CrowdloanContributeConfirmViewModel.swift in Sources */, AEE5FAFF26415E0C002B8FDC /* StakingRebondSetupPresenter.swift in Sources */, @@ -19636,10 +20335,12 @@ 84FB9E24285C6ACC00B42FC0 /* XcmMultiasset.swift in Sources */, 841E5547282D7F5800C8438F /* StakingMainPresenterFactory+Parachain.swift in Sources */, 84F4A90A254CC863000CF0A3 /* KeystoreExportWrapper.swift in Sources */, + 0CEB4ED52AF20EB90048FD84 /* CancellableCallHelper.swift in Sources */, 843074F928BF6201009D463B /* NoAccountSupportPresentable.swift in Sources */, 77F189402A49972300E8B933 /* UILabel+bind.swift in Sources */, 8467FCFE24E5C50B005D486C /* KeystoreImportService.swift in Sources */, 847A259029B5D2710054F90C /* GovJsonLocalStorageHandler.swift in Sources */, + 77C9BCCE2ACD691300022EA2 /* SwapAssetsOperationViewFactory.swift in Sources */, 84C6801024D6EE4500006BF5 /* PlusIndicatorView.swift in Sources */, 8428765A24ADDE0200D91AD8 /* SettingsViewFactory.swift in Sources */, 849E17E8279143B2002D1744 /* NavigationBarStyle.swift in Sources */, @@ -19674,8 +20375,10 @@ AEF7404E25E6DC9400407D41 /* InflationCurveRewardEngine.swift in Sources */, 848CCB522833D29F00A1FD00 /* StakingParachainStatics.swift in Sources */, 84CA68D926BE9E7F003B9453 /* SpecVersionSubscription.swift in Sources */, + 0C13DFCB2AF6182500E5F355 /* SwapModel.swift in Sources */, 84C74363251E4C2F009576C6 /* DummySigner.swift in Sources */, 8454C26A2632B8CE00657DAD /* BalanceDepositEvent.swift in Sources */, + 0C0CB38A2AC56A1600EAC516 /* AssetConversionPallet+Path.swift in Sources */, 84DD5F3E263DE5FF00425ACF /* DataValidationProtocols.swift in Sources */, 88F34FD928FFE68B00712BDE /* YourVoteRow.swift in Sources */, 84D8F15F24D8179000AF43E9 /* TitleWithSubtitleViewModel.swift in Sources */, @@ -19712,6 +20415,7 @@ 841221A028F051EE00715C82 /* GovMetadataLocalStorageSubscriber.swift in Sources */, 84ABB3322A16150400B5E95A /* AssetReceiveInfo.swift in Sources */, 84100F3626A6069200A5054E /* IconTitleValueView.swift in Sources */, + 771901A82AE8FF9F00D9C918 /* SwapNetworkFeeViewCell.swift in Sources */, 88B560BC28F80DCB00A5EB59 /* VoteRowView.swift in Sources */, 84FCCD98292E3610002D2D3D /* JSONRPCError+Evm.swift in Sources */, 842876AB24AE049B00D91AD8 /* SelectionItemViewProtocol.swift in Sources */, @@ -19720,6 +20424,7 @@ 84FD3DBB254104B600A234E3 /* WalletTransactionListUpdated.swift in Sources */, 842EBB2928908ADB00B952D8 /* WalletsListTableViewCell.swift in Sources */, 84C1706629961FAD00CBE531 /* GovernanceYourDelegationsInteractorError.swift in Sources */, + 771901A62AE8FF7E00D9C918 /* SwapInfoViewCell.swift in Sources */, 8428765C24ADDE0200D91AD8 /* SettingsViewController.swift in Sources */, 84746B3028153E4C002642F4 /* GradientBannerModel.swift in Sources */, 8433B34A29B63661005E5D0F /* ExtrinsicSplitter.swift in Sources */, @@ -19735,6 +20440,7 @@ 0C154A0E2A45995500932C3F /* CompoundComparator.swift in Sources */, 88A0E10028A284C700A9C940 /* CurrencyManager.swift in Sources */, 848DE76826D64F7F0045CD29 /* UserStorageVersion.swift in Sources */, + 7719018E2AE0E71F00D9C918 /* SwapErrorPresentable.swift in Sources */, 84DB9E982640A49E00F23DD3 /* StakingRedeemViewModelFactory.swift in Sources */, 84FBECFD292764DA00FBEB83 /* WalletRemoteEvmSubscriptionService.swift in Sources */, 84ACEBF2261E664900AAE665 /* WalletHistoryFilterViewModel.swift in Sources */, @@ -19746,6 +20452,7 @@ AE7129B4260872ED000AA3F5 /* EraStakersInfoChanged.swift in Sources */, 842D1E9624D2DD6700C30A7A /* MnemonicDisplayView.swift in Sources */, 8411707A285B10F5006F4DFB /* XcmAssetTransferFee.swift in Sources */, + 77E304B52AEFC303006FD6F0 /* SwapNetworkFeeSheetViewFactory.swift in Sources */, 845B07F729162AB3005785D3 /* Democracy+ConstantPath.swift in Sources */, 84F4B2372A31D2F300113DDD /* ParitySignerType.swift in Sources */, 84DBEA56265ED62700FDF73C /* BaseErrorPresentable.swift in Sources */, @@ -19773,6 +20480,7 @@ 84E2ABC82992724600A5D3C1 /* GovernanceDelegatorAction.swift in Sources */, 8463A70325E2FCD0003B8160 /* WeakWrapper.swift in Sources */, 8459A9CA2746A1BC000D6278 /* CrowdloanOffchainSubscriber.swift in Sources */, + 0C13DFC92AF4FFC200E5F355 /* SwapErrorPresentableParams.swift in Sources */, 8401620B25E144D50087A5F3 /* AmountInputAccessoryView.swift in Sources */, 88F19DE028D8D0F600F6E459 /* LoadableViewModelState+Addition.swift in Sources */, 844C3E652A07627E00C4305F /* DAppWalletAuthViewModel.swift in Sources */, @@ -19833,6 +20541,7 @@ 844CB57426FA01DD00396E13 /* SubstrateRepositoryFactory.swift in Sources */, 849013DF24A927E2008F705E /* SettingsExtension.swift in Sources */, 888797B829F269130078633F /* BiometrySettings.swift in Sources */, + 7719019D2AE6996600D9C918 /* SwapPairView.swift in Sources */, 842898CC265A8EC3002D5D65 /* RemoteImageViewModel.swift in Sources */, 84DA3B1424C6D7C700B5E27F /* RuntimeDispatchInfo.swift in Sources */, 840D92A7278EE8690007B979 /* DAppSignBytesConfirmInteractor.swift in Sources */, @@ -20030,6 +20739,7 @@ 77A6F5C92A2F1EFC004AFD1A /* AssetOperationViewFactory.swift in Sources */, 848E6BD92761CAC800C91022 /* StakingRewardView.swift in Sources */, F4B39C592732712C00BB6E10 /* AcalaContributionMethod.swift in Sources */, + 0C13DFDF2AFA89EF00E5F355 /* Decimal+Percents.swift in Sources */, AEA0C8BA268113F900F9666F /* YourValidatorList+RecommendedList.swift in Sources */, 88421056289BBA8D00306F2C /* CurrencyPresenter.swift in Sources */, 84D33996262250B800130A89 /* String+Substrate.swift in Sources */, @@ -20052,10 +20762,12 @@ 84953F662934C7D90033F47D /* EtherscanERC20HistoryResponse.swift in Sources */, AEAC68F526E9F93B00346599 /* CoingeckoDefinitions.swift in Sources */, 8410DBCB26EA31DE00FE1738 /* AccountProviderFactory.swift in Sources */, + 0CD3A67C2AEAA3B90059BBEC /* AssetConversionFeeService.swift in Sources */, 841E554F282E2C0300C8438F /* StakingParachainInteractor+InputProtocol.swift in Sources */, 84B1318C29ED70BF004EA1FF /* EvmFallbackGasLimit.swift in Sources */, 84DB9E902640A48E00F23DD3 /* StakingRedeemViewModel.swift in Sources */, 84D8F16124D8193200AF43E9 /* IconWithTitleTableViewCell.swift in Sources */, + 77C976202AF36A170049272C /* SwapModels.swift in Sources */, 844ADE7E28CB351500EE29F7 /* AutomationTimeAutocompound.swift in Sources */, 84C41F3628EDADE000DB1CD3 /* ReferendaTrackInfo.swift in Sources */, AEF507F226259DF00098574D /* ValidationViewModel.swift in Sources */, @@ -20098,6 +20810,7 @@ AEA2C1B62681E9B20069492E /* ValidatorSearchViewFactory.swift in Sources */, 84AE7ABB27D411CE00495267 /* RMRKV2DetailsInteractor.swift in Sources */, 84FFE4572861FFE5002432BB /* FeeWithWeight.swift in Sources */, + 77AAE22A2AFC36EE006872CC /* OperationDetailsBaseInteractor.swift in Sources */, AE4A71D42607B1440017C663 /* NetworkStakingInfoViewModel.swift in Sources */, 8439398A2636E8840087658D /* YourValidatorListViewLayout.swift in Sources */, 84DB4E2325E945E000A6DF41 /* SlashingSpans.swift in Sources */, @@ -20126,6 +20839,7 @@ 84FB298426393D0900BE0FCD /* YourValidatorListViewModelFactory.swift in Sources */, 842E9E942A2A277D00759972 /* StakingDashboardLocalStorageSubscriber.swift in Sources */, 8489A6D827FDA51C0040C066 /* AccountLocalSubscriptionHandler.swift in Sources */, + 0CB64E602B00AD83008F268F /* GetTokenOptionsWireframe.swift in Sources */, 8406B5AB26FBD9EB00635B61 /* AccountInfoUpdatingService.swift in Sources */, 844B2E7A27C425E3000CC079 /* NftLocalStorageSubscriber.swift in Sources */, AE6F7FE62685F2C3002BBC3E /* ValidatorListFilterViewModel.swift in Sources */, @@ -20148,6 +20862,7 @@ 8430AAE926022F69005B1066 /* StashState.swift in Sources */, 847ABE3128532E1B00851218 /* ConsesusType.swift in Sources */, 8469529C28528C9C0083E0B4 /* EraCountdownOperationFactoryProtocol.swift in Sources */, + 77AAE2222AFB026E006872CC /* OperationDetailsSwapProvider.swift in Sources */, 8428768424AE046300D91AD8 /* LanguageSelectionInteractor.swift in Sources */, 841E5542282D525F00C8438F /* ParastakingLocalStorageHandler.swift in Sources */, F4A12BF7260B61E900392C33 /* StakingManageOption.swift in Sources */, @@ -20252,8 +20967,10 @@ 88C5F082297F0706001CCADE /* ReleaseVersion.swift in Sources */, 849013E224A9288B008F705E /* Language.swift in Sources */, 840D92A1278D8D6F0007B979 /* DAppBrowserStateError.swift in Sources */, + 77ECB4702ACEEE2E0015CE9F /* SwapNetworkFeeView.swift in Sources */, 84FC190B29B7DB9F00BCCAA5 /* ExtrinsicServiceTypes.swift in Sources */, 849707A128F3E0AC00DD0A02 /* ReferendumVoterLocal.swift in Sources */, + 774091FC2ACC053000172516 /* SwapAssetView.swift in Sources */, 84754C882510BAFE00854599 /* ModalAlertFactory.swift in Sources */, 8430AACC2602249B005B1066 /* InitialStakingState.swift in Sources */, 0C56B4FB2A4B0C320030F9C9 /* AssetListBaseBuilder.swift in Sources */, @@ -20284,6 +21001,7 @@ 8490145824A9406D008F705E /* LegalData.swift in Sources */, 8472C601265D7A1F00E2481B /* WebSocketProviderSource.swift in Sources */, 8425EA9025EA7E5800C307C9 /* ElectedValidatorInfo.swift in Sources */, + 0C500B222B05102900ABEE70 /* AssetTxPaymentPallet.swift in Sources */, 84216FCF28264A1E00479375 /* ParaStakingRewardCalculatorEngine.swift in Sources */, 84DA03D62759341200E8B326 /* ChainAccountControl.swift in Sources */, 84329ED02832461D0020BC1C /* TimeInterval+Localization.swift in Sources */, @@ -20292,6 +21010,7 @@ 84744953289268770042FD80 /* WalletSwitchViewModel.swift in Sources */, 84FACCD925F8C22A00F32ED4 /* BigInt+Hex.swift in Sources */, 77799AF02A7CFB7C00B7E564 /* ValidatorViewModel.swift in Sources */, + 77C9BCCA2ACD574800022EA2 /* SwapAssetOperationWireframe.swift in Sources */, 849F33BC29F7C659001AEFA4 /* DAppInteractionProtocols.swift in Sources */, F409672626B29B04008CD244 /* UIScrollView+ScrollToPage.swift in Sources */, 84E0EE0E292D69A9008B2953 /* CallMetadata+TypeCheck.swift in Sources */, @@ -20349,6 +21068,7 @@ 84D8F16324D8194100AF43E9 /* TitleWithSubtitleTableViewCell.swift in Sources */, 0C9C64322A8D67A0004DC078 /* StakingNPoolsInteractor.swift in Sources */, F462B35C260C86880005AB01 /* ViewHolder.swift in Sources */, + 0CD3529B2ACD3E4300B3E446 /* PalletAssets+Call.swift in Sources */, 8846F73E29D7561100B8B776 /* Data+base36.swift in Sources */, 84948C36287DD1C800E6DD3E /* NftListRMRKV2ViewModel.swift in Sources */, 84C515FB26D84F8C000DBA45 /* AccountImportWrapper.swift in Sources */, @@ -20433,6 +21153,8 @@ 84E25BF027E8EFB500290BF1 /* SubqueryAccumulateReward.swift in Sources */, 88AC5ADA2948A8CC0056DD40 /* TransactionSectionModel.swift in Sources */, 8499FEE227C0AF4700712589 /* ChainModel+Nft.swift in Sources */, + 0CFA161E2B0CED07007AF885 /* GovTreasurySpentAmountHandler.swift in Sources */, + 0CB64E692B01F798008F268F /* TransferSetupOriginSelectionWireframe.swift in Sources */, 8428765424ADDE0200D91AD8 /* SettingsViewModelFactory.swift in Sources */, 843612C1278FE62900DC739E /* DAppOperationConfirmInteractorError.swift in Sources */, 84E1CD02260DCC62001E81B5 /* SwitchAccount+OnboardingMainWireframe.swift in Sources */, @@ -20496,7 +21218,6 @@ 849013D024A9267F008F705E /* R.generated.swift in Sources */, 844CED2529260B6A001A7757 /* EvmQueryContractMessageFactory.swift in Sources */, 8442003828EAA16600C49C4A /* ReferendumsPresenter.swift in Sources */, - 84FB9E22285C5D6200B42FC0 /* XcmNetworkId.swift in Sources */, 84DC3CE52796768A0038E2ED /* SubqueryHistoryContext.swift in Sources */, 848077D22837CAE5003B7C79 /* ParachainStakingErrorPresentable.swift in Sources */, 84F4387025D9BC3900AEDA56 /* EmptyStreamableSource.swift in Sources */, @@ -20507,6 +21228,7 @@ 8401AEC82642A71D000B03E3 /* StakingRebondConfirmationProtocols.swift in Sources */, 849014BA24AA87E4008F705E /* PinSetupProtocol.swift in Sources */, F418E887264D308700699085 /* StakingAlert.swift in Sources */, + 0CB64E5A2AFE9947008F268F /* GetTokenOperation.swift in Sources */, 84DD5F7C263DFEC600425ACF /* StakingDataValidatorFactory.swift in Sources */, 779107682AB8CA71000A4B17 /* AssetListSearchEmptyCell.swift in Sources */, 84E1CCF5260DCB91001E81B5 /* SwitchAccount+WalletManagementWireframe.swift in Sources */, @@ -20541,6 +21263,7 @@ 8472974D260A9CDF009B86D0 /* SelectValidatorsConfirmationModel.swift in Sources */, 840D627C29CC369100D5E894 /* CoingeckoStreamableSource.swift in Sources */, 84FF267C28494B13003EC78D /* ParaStkStakeSetupPresenter+StartStaking.swift in Sources */, + 0C13DFCF2AF8ADB300E5F355 /* BigUInt+Operation.swift in Sources */, 88D02FEA2942ED4F00E26390 /* RoundedButton+Style.swift in Sources */, 842BDB2A278C4F3C00AB4B5A /* DAppBrowserAuthorizingState.swift in Sources */, 84FEADF228783F55001DFC26 /* BaseParaStakingRewardCalculatoService.swift in Sources */, @@ -20557,6 +21280,7 @@ 84B6349D28F4A06D00503306 /* Preimage.swift in Sources */, 8428768524AE046300D91AD8 /* LanguageSelectionProtocols.swift in Sources */, AEA0C8A4267B6B1900F9666F /* SelectedValidatorListProtocols.swift in Sources */, + 0CB64E652B01E0CC008F268F /* TransferNetworkSelectionCell.swift in Sources */, 0C3205EC2A8A122D002EB914 /* FeeOutputModel.swift in Sources */, 84D911AA292C923D0032EF33 /* Data+Fill.swift in Sources */, F4B39C4E27326E8400BB6E10 /* AcalaContributionSetupViewController.swift in Sources */, @@ -20583,6 +21307,7 @@ 0C17BD9B2A43025E004AF9E7 /* Pagination.swift in Sources */, 88D02FE82942EB1A00E26390 /* AssetDetailsModel.swift in Sources */, 77F033A22A84E00F006BC67E /* StakingPoolView.swift in Sources */, + 77E304A92AEB9F76006FD6F0 /* SwapConfirmInitState.swift in Sources */, 88421055289BBA8D00306F2C /* CurrencyViewLayout.swift in Sources */, 845C407D2702812E00BFA50B /* StakingAccountUpdatingService.swift in Sources */, 8430D6C92801A2B500FFB6AE /* WebSocketProtocols.swift in Sources */, @@ -20628,6 +21353,7 @@ 8846F74029D757FE00B8B776 /* Data+base64.swift in Sources */, 84EC2D16276B92CB009B0BE1 /* DAppScriptResponse.swift in Sources */, F43A596A26D520E0005E973D /* EmptyStateViewCell.swift in Sources */, + 0CA50CAF2AFE6094005668CD /* GetTokenOptionsViewController.swift in Sources */, 841E556D282EC50700C8438F /* ParaStkStateMachine.swift in Sources */, 8846F73329D6BEEA00B8B776 /* Data+base64url.swift in Sources */, 8487584927F1830D00495306 /* QRImageUploadDelegate.swift in Sources */, @@ -20646,6 +21372,7 @@ 84757E19299A2E2F00616C6C /* Gov2SubscriptionFactory+Referendum.swift in Sources */, 8473F4BA282C012B007CC55A /* StakingRelaychainInteractor+InputProtocol.swift in Sources */, 84E0EE0C292D402C008B2953 /* GovernanceAssetSelectionViewFactory.swift in Sources */, + 0CEB4ED92AF371EF0048FD84 /* AssetConversionTxPayment.swift in Sources */, 848DAEFE282293DB00D56F55 /* ParachainStaking+Types.swift in Sources */, 8489A6E227FEFC730040C066 /* StakingRebondOption.swift in Sources */, 84CFF1E626526FBC00DB7CF7 /* StakingBondMoreInteractor.swift in Sources */, @@ -20722,6 +21449,7 @@ 848DE76226D62AFE0045CD29 /* PersistentStoreCoordinator+Extensions.swift in Sources */, 845FB6752A2274890003BCA6 /* MultistakingRepositoryFactory.swift in Sources */, 9942034DCB680824831B0AC1 /* AccountCreatePresenter.swift in Sources */, + 7719019B2AE670AE00D9C918 /* ShortTextInfoPresentable.swift in Sources */, 840E59B72A187A9200BA6ADD /* GladingPatternView.swift in Sources */, AEA2C1B82681E9BD0069492E /* ValidatorSearchWireframe.swift in Sources */, 846952A62852A1E60083E0B4 /* AuraStakingDurationFactory.swift in Sources */, @@ -20761,10 +21489,12 @@ 847999A9288862A500D1BAD2 /* MetaAccountOperationFactory+Secrets.swift in Sources */, 8496ADD7276AFEED00306B24 /* DAppBrowserModel.swift in Sources */, 8473B4782A207FE0003DE213 /* StakingDashboardOffchainMapper.swift in Sources */, + 77D2E2712B05416E0098F188 /* AssetListGroupModelCompator.swift in Sources */, 84F4386625D9B8C600AEDA56 /* TypeRegistryPrepared.swift in Sources */, 84282298289BC50A00163031 /* SwitchAccount+ParitySignerAddConfirmWireframe.swift in Sources */, AE805FC526B3DF8B00007CE9 /* ValidatorInfoInteractorBase.swift in Sources */, 84FB9E28285C736A00B42FC0 /* XcmTransferFactory.swift in Sources */, + 0CEB4ED12AF14B8B0048FD84 /* SubmoduleNavigationStrategy.swift in Sources */, AEE5FB0526415E5D002B8FDC /* StakingRebondSetupViewFactory.swift in Sources */, AEAC68F726E9FB8400346599 /* CoingeckoOperationFactory.swift in Sources */, 843910C5253F561500E3C217 /* CompoundOperationWrapper+Result.swift in Sources */, @@ -20796,12 +21526,14 @@ 2AC7BC7E2731604C001D99B0 /* ChainAccountChanged.swift in Sources */, 845B07F329159C15005785D3 /* Democracy+CodingPath.swift in Sources */, 847C96492553614F002D288F /* ExportRestoreJsonPresenter.swift in Sources */, + 0C13DFD32AF949B400E5F355 /* BalanceViewModelFactoryFacade+Formatting.swift in Sources */, 84A04622277DE83E000B24DA /* DAppListErrorView.swift in Sources */, 6D315EFF2B664235D297674E /* AccountImportProtocols.swift in Sources */, F43A8A9E265BA03500A8A5A8 /* CustomValidatorListViewModel.swift in Sources */, 8477DAA6288832CB00129B45 /* WatchOnlyPresetRepository.swift in Sources */, AEA2C1BC2681E9CD0069492E /* ValidatorSearchPresenter.swift in Sources */, 845B821D26EF80DB00D25C72 /* ChainAccountModel.swift in Sources */, + 77AAE2242AFB67BE006872CC /* OperationSwapDetailsInteractor.swift in Sources */, F4617415260E23E900E8FA3D /* StakingRewardStatusViewModel.swift in Sources */, 4A520B7081BE2D7604B69354 /* AccountImportWireframe.swift in Sources */, 840B3D6C2899CD5A00DA1DA9 /* ParitySignerScanMatcher.swift in Sources */, @@ -20809,6 +21541,7 @@ 7D281FEA78E2E5F44990C184 /* AccountImportPresenter.swift in Sources */, 6C56AB4AE63AB2DC73DE98E0 /* AccountImportInteractor.swift in Sources */, F4EAC7972642E0D800FBDDC3 /* ControllerAccountViewModelFactory.swift in Sources */, + 0C0CB3852AC561FB00EAC516 /* AssetHubSwapOperationFactory.swift in Sources */, 775F19512A5811FA009915B6 /* StartStakingParachainInteractor.swift in Sources */, 8828F4F328AD2734009E0B7C /* CrowdloansCalculator.swift in Sources */, 8487583627F06AF300495306 /* QRScannerViewController.swift in Sources */, @@ -20867,6 +21600,7 @@ 8892284828F353A5003F8B9E /* ReferendumsModelFactory.swift in Sources */, 88D02FE32942EA2200E26390 /* PayButtonsRow.swift in Sources */, 849A4EF2279A787200AB6709 /* AssetsUpdatingService.swift in Sources */, + 0CD3A67E2AEAAB670059BBEC /* AssetHubFeeService.swift in Sources */, 84B018AC26E01A4100C75E28 /* StakingStateView.swift in Sources */, 842A737C27DCC489006EE1EA /* OperationDetailsTransferView.swift in Sources */, 84880C4429026C3E00CADB06 /* ReferendumDelegatingLocal.swift in Sources */, @@ -20894,6 +21628,7 @@ F419FD7A273D05B00061652C /* SettingsSection.swift in Sources */, DAEE468553039B3600F64A0E /* AccountManagementWireframe.swift in Sources */, F4E117B9264BAA81006F03B0 /* ControllerAccountConfirmationVM.swift in Sources */, + 77740BBC2AD4A7B800E8C06F /* CollapsableContainerView.swift in Sources */, AE74EBCA25F90CA500C494E7 /* URL+Helpers.swift in Sources */, 8468B87024F63D2000B76BC6 /* AddAccount+UsernameSetupWireframe.swift in Sources */, 84754CA42513E6DC00854599 /* PrimitiveContextWrapper.swift in Sources */, @@ -20925,6 +21660,7 @@ 845B89262959627A00EE25B0 /* SecurityLayerWireframe.swift in Sources */, 777AE2AB2ABCB4A5004989C0 /* HtmlParsingOperationFactory.swift in Sources */, 8489A6D227FD5FB80040C066 /* StackActionCell.swift in Sources */, + 0CA50CB52AFE6F31005668CD /* GetTokenOptionsInteractor.swift in Sources */, F429324F26280F6B00752C2C /* StakingRewardDetailsViewModel.swift in Sources */, F4A11B5A272FEB0B0030E85B /* CrowdloanYourContributionsCell.swift in Sources */, 0C79C8992A7BE46A00B171E3 /* AssetModel+Staking.swift in Sources */, @@ -20953,6 +21689,8 @@ F4D0546B2729949100210294 /* MoonbeamMakeSignatureResponse.swift in Sources */, D9046DBA27451ED700C29F2E /* ParallelContributionSource.swift in Sources */, 0CE629DE2AA9B6BF00E250BD /* RewardDestinationViewModelFactory.swift in Sources */, + 77C976222AF39F180049272C /* TokenOperationTableViewCell.swift in Sources */, + 77C9BCBE2ACD286100022EA2 /* SwapsSetupViewModelFactory.swift in Sources */, 844DB624262D9C710025A8F0 /* ErasRewardDistribution.swift in Sources */, 84FAB0632542C8D600319F74 /* ContactItem.swift in Sources */, 06590486EED4050BADDD32C5 /* AccountManagementPresenter.swift in Sources */, @@ -20977,7 +21715,6 @@ 84DC3CE32795E0340038E2ED /* SubqueryHistoryOperationFactory+RemoteHistory.swift in Sources */, 5678BAE4B652C5C5E4284F28 /* AccountManagementViewFactory.swift in Sources */, 84CFE441292B8CDA00CDDD7C /* EvmOnChainTransferSetupInteractor.swift in Sources */, - 849528E326036997009DC845 /* RewardEstimationView.swift in Sources */, 77864F4C2A6AC5A100FA7BA7 /* TitleHorizontalMultiValueView+Bind.swift in Sources */, 88784E9D29BF966B004489D5 /* Web3NamesOperationFactory.swift in Sources */, 847999B628894FE200D1BAD2 /* AccountInputViewDelegate.swift in Sources */, @@ -21091,6 +21828,7 @@ 84E258A12892CC0F00DC8A51 /* AccountManagementViewLayout.swift in Sources */, 848FD1A528AE27E700CCD9E2 /* HardwareWalletOption.swift in Sources */, 845CB6FA26276326005F798B /* LongrunOperation.swift in Sources */, + 0C13DFDB2AFA691400E5F355 /* SlippageConfig.swift in Sources */, 8499FEC627BEC68000712589 /* UniquesInstanceMetadata.swift in Sources */, 840EAE6729FA8F0000453C7E /* WalletConnectDecision.swift in Sources */, 84EBFCE9285E7C100006327E /* XcmInstruction.swift in Sources */, @@ -21118,13 +21856,16 @@ 847EA1D62A1CA47500F1CBD8 /* SubqueryMultistaking.swift in Sources */, 8485D924277E16C400767243 /* DAppBrowserScriptHandler.swift in Sources */, 8427495528FEB92700B2B70B /* GovernanceLockStateFactory.swift in Sources */, + 0C2200692ACA80BB0067BA61 /* AssetHubSwapQuoteBuilder.swift in Sources */, 0C9680F32A8AC2F2006A411B /* EvmTokenAddResult.swift in Sources */, 842BDB2C278C4FFE00AB4B5A /* DAppBrowserAuthorizedState.swift in Sources */, 849346AF2A1F7F7D00CB75B7 /* MultistakingServices.swift in Sources */, 848919DB26FB663D004DBAD5 /* CrowdloansChainViewModel.swift in Sources */, 842A736D27DB7B5E006EE1EA /* OperationTransferViewModel.swift in Sources */, 842B18022864F9950014CC57 /* CrossChainTransferPresenter.swift in Sources */, + 0CB64E672B01E174008F268F /* TransferNetworkSelectionViewModel.swift in Sources */, 84C1DBBA29C0A11200F295A5 /* XcmTransferService+Fee.swift in Sources */, + 77C9BCD02ACD8A3600022EA2 /* SwapAssetsOperationViewLayout.swift in Sources */, 845B081529190056005785D3 /* Gov1UnlockReferendum.swift in Sources */, 84B24FB02A2F7B6F00F9BF59 /* StakingDashboardMoreOptionsCell.swift in Sources */, 77799ADD2A74219A00B7E564 /* ButtonState.swift in Sources */, @@ -21157,6 +21898,7 @@ 84AEEE6A27A9204C005EBA77 /* AssetListSettingsCell.swift in Sources */, F466AA85273D0D4200D14021 /* SettingsSectionHeaderView.swift in Sources */, 842BDB25278C3ACB00AB4B5A /* DAppBrowserMetadataState.swift in Sources */, + 77C976242AF3A5280049272C /* SwapViewModels.swift in Sources */, 64B508A1A3D820AA8DBCFAA3 /* AccountExportPasswordWireframe.swift in Sources */, F462B351260C7DBE0005AB01 /* StakingRewardHistoryTableCell.swift in Sources */, 840302E8292D00380013F356 /* GovernanceAssetSelectionPresenter.swift in Sources */, @@ -21185,6 +21927,7 @@ 1BFC90E1D8646F7429FFD5E6 /* ExportMnemonicProtocols.swift in Sources */, 848B59BA28BCB3CA0009543C /* LedgerBaseAccountConfirmationWireframe.swift in Sources */, 3133215566E418F40844A60E /* ExportMnemonicWireframe.swift in Sources */, + 771901B22AEA3E5100D9C918 /* ShortTextInfoPresentableExtensions.swift in Sources */, 8446F5F02817279600B7A86C /* HintListView.swift in Sources */, 84BFE89728C2314300140F1F /* ParaStkYieldBoostOperationFactory.swift in Sources */, 84A70DA129CCEDC800C648AD /* GovV2SubsquareOperationFactory.swift in Sources */, @@ -21215,6 +21958,7 @@ 848DAEF7282274E700D56F55 /* ParachainStakingRemoteSubscriptionService.swift in Sources */, 845B821F26EF8E8900D25C72 /* ManagedMetaAccountModel.swift in Sources */, 88F34FD428FFE64400712BDE /* ReferendumDAppCellView.swift in Sources */, + 0CA7821C2B03D0A9003F562A /* ExtrinsicProcessor+SwapMatching.swift in Sources */, 8430AB1226023C9F005B1066 /* PendingBondedState.swift in Sources */, 88421059289BBA8D00306F2C /* CurrencyViewFactory.swift in Sources */, 84F76ED829006BC400D7206C /* DiscreteGradientSlider+Style.swift in Sources */, @@ -21226,6 +21970,7 @@ 848F5FE52989130B0058CD74 /* GovernanceOffchainDelegations.swift in Sources */, 84F1CB3327CE575E0095D523 /* NftListUniquesViewModel.swift in Sources */, AEBE173B262F3E6600DF257C /* StakingPayoutConfirmViewModelFactory.swift in Sources */, + 0C500B272B07314000ABEE70 /* ExtrinsicProcessingResult.swift in Sources */, 84B64E3F2704567700914E88 /* StakingLocalStorageSubscriber.swift in Sources */, 847449512891F3B00042FD80 /* WalletSwitchControl.swift in Sources */, AE3983A2272C08AE00BC8A85 /* BaseAccountImportPresenter.swift in Sources */, @@ -21253,6 +21998,7 @@ F4F22976260DBF3F00ACFDB8 /* StakingPayoutRewardTableCell.swift in Sources */, 8428229A289BC8E400163031 /* AddAccount+ParitySignerWelcomeWireframe.swift in Sources */, 8471577D2910F18300D7D003 /* GovernanceUnlockProtocols.swift in Sources */, + 0C500B1F2B04EA9100ABEE70 /* AssetConversionPallet+Event.swift in Sources */, 2CF2F93AF862CF54FC46B560 /* PurchaseInteractor.swift in Sources */, 0C9951D32AE2DB0200B65615 /* PromotionViewModelFactory.swift in Sources */, 77EFFC912A7276F1009E28F8 /* StakingTypeAccountView.swift in Sources */, @@ -21280,6 +22026,7 @@ C01C5F1C8CB67B0D5CBE9FB1 /* StakingMainPresenter.swift in Sources */, 843F657A265854A700829C14 /* CrowdloanDisplayInfo.swift in Sources */, 0C13D31F2A8227AB0054BB6F /* StartStakingPoolConfirmPresenter.swift in Sources */, + 0CCA245D2AC6918800AEF23D /* XcmV3Junction.swift in Sources */, 8412AF992789AB76008A6C22 /* PolkadotExtensionMetadata.swift in Sources */, 847999AF2888A45700D1BAD2 /* AddAccount+CreateWatchOnlyWireframe.swift in Sources */, 846E5010277998040049B659 /* DAppAuthRequest.swift in Sources */, @@ -21337,6 +22084,7 @@ 84F6B6502619E1ED0038F10D /* Int+Operations.swift in Sources */, 8487010E2907DF2F00F2C0C3 /* MultiValueView+Style.swift in Sources */, 84770F2A291F864500852A33 /* GovernanceUnlockInitData.swift in Sources */, + 7719018C2AE0E70B00D9C918 /* SwapDataValidatorFactory.swift in Sources */, 85547F698B551ACD387D84E2 /* SelectValidatorsStartViewController.swift in Sources */, 845B07F129159AE7005785D3 /* DemocracyVoting.swift in Sources */, 41B29C1C9239BB2DCB7903A7 /* SelectValidatorsStartViewFactory.swift in Sources */, @@ -21418,9 +22166,11 @@ 84BB3CEE267CD6B500676FFE /* CrowdloanContributionDict.swift in Sources */, 8473F4B4282BD5A1007CC55A /* StakingRelaychainInteractor.swift in Sources */, 845B821B26EF80BC00D25C72 /* MetaAccountModel.swift in Sources */, + 77E304B02AEFA261006FD6F0 /* SwapNetworkFeeSheetLayout.swift in Sources */, 88FB7DCF295071B100784E08 /* ContainerViewController.swift in Sources */, 841E5536282CDB9E00C8438F /* StakingMainPresenterFactory+Relaychain.swift in Sources */, 8499FEC827BF73F400712589 /* StorageKeyFactory+Size.swift in Sources */, + 77E304B72AEFC348006FD6F0 /* SwapNetworkFeeSheetViewModel.swift in Sources */, 0CB0646A2A40572C00BFBA3F /* AmountInputViewModel.swift in Sources */, 847A25CA28D85204006AC9F5 /* ReferendumInfo.swift in Sources */, 5869563D0EA593FBD02C169C /* StakingPayoutConfirmationProtocols.swift in Sources */, @@ -21430,6 +22180,7 @@ 841E556B282EAC3600C8438F /* ParachainStakingDelegatorState.swift in Sources */, 880E40FF298CF1ED0077B18B /* VotesProtocols.swift in Sources */, 84FEF3E528089FFB0042CBE7 /* TextInputField.swift in Sources */, + 0CEB4ED32AF1689D0048FD84 /* AssetConversionAggregationFactory.swift in Sources */, 84D17EE12805A62600F7BAFF /* DAppAlertPresentable.swift in Sources */, A871B6ABACAE8A811010F792 /* StakingPayoutConfirmationWireframe.swift in Sources */, 1795E946F1E386442E96E2BC /* StakingPayoutConfirmationPresenter.swift in Sources */, @@ -21518,6 +22269,8 @@ 8498534F2A17390900993977 /* PalletAssets.swift in Sources */, 845B08042918C308005785D3 /* Gov1ActionOperationFactory.swift in Sources */, F0C3DB0CEE1975626B0014A8 /* StakingUnbondConfirmInteractor.swift in Sources */, + 0C0CB3822AC545A800EAC516 /* AssetConversionExtrinsicService.swift in Sources */, + 77A4F4032B036615006294BC /* Optional+Result.swift in Sources */, 849FA21628A26CB500F83EAA /* CountdownTimerMediator.swift in Sources */, D3B48F82A875E301D749AC0B /* StakingUnbondConfirmViewController.swift in Sources */, 842AEB81292F34B600C61B0C /* RemoteChainExternalApi.swift in Sources */, @@ -21528,9 +22281,11 @@ 843E9B2827C83985009C143A /* AssetListNftsCell.swift in Sources */, 3E1462D9E1C0D490E81FD288 /* StakingUnbondConfirmViewFactory.swift in Sources */, 9B4BE26140C63E07C256CC97 /* StakingRedeemProtocols.swift in Sources */, + 0C13DFD12AF8AE3E00E5F355 /* SwapBasePresenter.swift in Sources */, 88FB7DD12950720800784E08 /* ContainerProtocols.swift in Sources */, 840AE2E529C9AF9C008FF665 /* EtherscanWalletHistoryDecodable.swift in Sources */, 772B1C7D2A93837800A19061 /* StartStakingCustomValidatorListWireframe.swift in Sources */, + 77AAE2202AFB00CB006872CC /* OperationSwapModel.swift in Sources */, 8465DA35298EC5FB00C7CFF1 /* TitleDetailsSheetLayout.swift in Sources */, AEE5FB1C264A610C002B8FDC /* StakingRewardDestSetupLayout.swift in Sources */, 84350ADB28461E5B0031EF24 /* ParaStkYourCollatorsViewModelFactory.swift in Sources */, @@ -21555,12 +22310,14 @@ 845B07EF2915951A005785D3 /* DemocracyVoteThreshold.swift in Sources */, 885551F78A5926D16D5AF0CB /* ControllerAccountPresenter.swift in Sources */, 8442002F28E9AEFB00C49C4A /* VoteWireframe.swift in Sources */, + 0CCA245F2AC6974200AEF23D /* XcmV3Multilocation.swift in Sources */, 88E5E2A5295D8F8E001B1D41 /* DAppGlobalSettingsViewModel.swift in Sources */, 841E553C282D44BA00C8438F /* ParachainStakingAccountSubscriptionService.swift in Sources */, 84C479C129309E58003DF82B /* BasePolkassemblyOperationFactory.swift in Sources */, 84300B2C26C10C9B00D64514 /* ConnectionStateReporting.swift in Sources */, 9E4E458C92D12B24D5EAD893 /* ControllerAccountInteractor.swift in Sources */, 83A98B972B3EA69B357E5002 /* ControllerAccountViewController.swift in Sources */, + 0CB64E622B012E92008F268F /* TransferSetupPeer.swift in Sources */, 84329ED22832636F0020BC1C /* RoundCountdown.swift in Sources */, 844A539529BF54BA00C77111 /* XcmPalletMetadataQueryFactory.swift in Sources */, 84FBED072927B3B000FBEB83 /* EvmTransactionHistoryUpdaterFactory.swift in Sources */, @@ -21613,10 +22370,12 @@ 846DA5592A2098BE006CD6C1 /* StakingResolvedAccountMapper.swift in Sources */, D83B47B07C0D40A327AC44F7 /* CustomValidatorListViewFactory.swift in Sources */, 8881043829EBC6BD000FA9BC /* EquilibriumTokenTransfer.swift in Sources */, + 77A4F4012B035027006294BC /* AssetOperationState.swift in Sources */, 8487583427F06AF300495306 /* QRScannerPresenter.swift in Sources */, 0E6C2939AFB3D125C760D5A0 /* CrowdloanContributionSetupProtocols.swift in Sources */, 8410562C27AF1C15004F5CA3 /* Ethereum+Checksum.swift in Sources */, 4E5CD7B8821FA5298EA1598E /* CrowdloanContributionSetupWireframe.swift in Sources */, + 77C976262AF421AE0049272C /* OperationDetailsSwapView.swift in Sources */, 0CC2E5622A6E5C43004092E7 /* NominationPoolsAccountUpdatingService.swift in Sources */, 8846F72029D56A0700B8B776 /* Web3NameAddressListPresentable.swift in Sources */, 77A6F5C12A2F1724004AFD1A /* MessageSheetPresentable+presentOperationResult.swift in Sources */, @@ -21632,6 +22391,7 @@ AE4C53E5268C6F8300B03CE8 /* ValidatorListFilterSortCell.swift in Sources */, 885A6C3229A374B600B65C1A /* ReferendumVotersLocalWrapperFactory.swift in Sources */, 842B17FB28648FDC0014CC57 /* ChainAssetViewModelFactory.swift in Sources */, + 77AAE2262AFC10EE006872CC /* PriceHistoryCalculatorFactory.swift in Sources */, 1062C095BC566A1EA8DE1C06 /* CrowdloanContributionSetupViewController.swift in Sources */, 849C7BDB2A1B236900434621 /* GladingBaseView.swift in Sources */, 84EE2FB32891442F00A98816 /* WalletManageViewFactory.swift in Sources */, @@ -21640,6 +22400,7 @@ 840B3D632899BBDD00DA1DA9 /* ParitySignerScanPresenter.swift in Sources */, 84FD91B029B08F7A007851D3 /* BaseTableSearchViewController.swift in Sources */, 1BEADE77C6236CB3BF719A47 /* CrowdloanContributionSetupViewFactory.swift in Sources */, + 7719019F2AE6C9DC00D9C918 /* SwapElementView.swift in Sources */, 845353BD2886EB1A006C871A /* ButtonLargeControl.swift in Sources */, 8466781827EC9CDA007935D3 /* PersistTransferDetails.swift in Sources */, 84800B02273301C800E1E4DD /* RemoteChainModel.swift in Sources */, @@ -21672,6 +22433,7 @@ 8407715628CB7D6B007DBD24 /* ParaStkYieldBoostScheduleInteractor.swift in Sources */, 84E2ABCA2992891300A5D3C1 /* GovernanceDelegateInteractor.swift in Sources */, 84D1ABDC27E1B4FE0073C631 /* ChainAssetViewModel.swift in Sources */, + 0CD352952ACAF59900B3E446 /* BigRational.swift in Sources */, AEE0C442272A9AF7009F9AD5 /* BaseChainAccountConfirmInteractor.swift in Sources */, 848CCB462832EF3400A1FD00 /* GeneralLocalStorageSubscriber.swift in Sources */, 84735E7B2881A57700BADC1B /* AssetsSearchFlowLayout.swift in Sources */, @@ -21695,6 +22457,7 @@ 880855F028D099F2004255E7 /* CrowdloanOnChainSyncService.swift in Sources */, 843CE3A627D2098100436F4E /* NftDetailsLabel.swift in Sources */, 84216FD42827982800479375 /* SelectedRoundCollators.swift in Sources */, + 77C9BCC62ACD571400022EA2 /* SwapAssetOperationPresenter.swift in Sources */, 0C2F868B2A725C3C00593C01 /* EraNominationPoolsChanged.swift in Sources */, 849E17DC27909179002D1744 /* DAppSearchQueryTableViewCell.swift in Sources */, 2AC7BC842731A214001D99B0 /* AssetLocks+Sort.swift in Sources */, @@ -21807,6 +22570,7 @@ 84350AD4284580F50031EF24 /* StakingTotalStakePresentable.swift in Sources */, 6B393DCF67CF97FDA580C69B /* DAppSearchPresenter.swift in Sources */, 84540172292F907B00213402 /* BlurBackgroundView.swift in Sources */, + 771901B02AE97DA500D9C918 /* SwapConfirmViewModels.swift in Sources */, DC682E96D056C069902B9C31 /* DAppSearchViewController.swift in Sources */, 286577D8FE44D1F9E7BBDCA9 /* DAppSearchViewLayout.swift in Sources */, 8825018E29D2E31F001BE6FB /* ModuleNameResolver.swift in Sources */, @@ -21855,11 +22619,13 @@ 84E25BE827E751B400290BF1 /* Charset+Encoding.swift in Sources */, 847999B12888A4FF00D1BAD2 /* SwitchAccount+CreateWatchOnlyWireframe.swift in Sources */, 0C3205D42A895EDA002EB914 /* EvmConstantGasLimitProvider.swift in Sources */, + 0CA50CB72AFE6F40005668CD /* GetTokenOptionsViewFactory.swift in Sources */, 0C3205DC2A89677B002EB914 /* EvmGasLimitProviderFactory.swift in Sources */, 8487580727EDEB9600495306 /* BorderedIconLabelView.swift in Sources */, 0C6F0C9E2A69723B007170C6 /* StartStakingStateProtocol.swift in Sources */, 84AE7AAD27D3839D00495267 /* StackCellViewModel.swift in Sources */, 848CC94628D9FC46009EB4B0 /* ConvictionVotingTally.swift in Sources */, + 77C976282AF426100049272C /* OperationSwapViewModel.swift in Sources */, 84953F6A2934C9E20033F47D /* EtherscanERC20OperationFactory.swift in Sources */, 0CE629D72AA9B5E200E250BD /* BalanceViewModelFactory.swift in Sources */, CD9359A2720F2EE1D4E09DF6 /* DAppTxDetailsWireframe.swift in Sources */, @@ -21868,6 +22634,7 @@ 9BADFCBF3AF5186094DB8D67 /* DAppTxDetailsInteractor.swift in Sources */, B409644ED1E20062A3EA0316 /* DAppTxDetailsViewController.swift in Sources */, DAD46B2B29A446C19A6ABF2D /* DAppTxDetailsViewLayout.swift in Sources */, + 0CD352972ACAFADA00B3E446 /* AssetConversionOperationFactory.swift in Sources */, 88AC186328CA3F0000892A9B /* GenericCollectionViewLayout.swift in Sources */, A97F32D057BFEFBCC478A09C /* DAppTxDetailsViewFactory.swift in Sources */, D567BAAF620EDB9F4975C800 /* DAppAuthConfirmProtocols.swift in Sources */, @@ -21901,9 +22668,11 @@ 8465DA3D298ECF2500C7CFF1 /* GovernanceAddDelegationTracksProtocols.swift in Sources */, 6797F109D7C270DE4877B435 /* NftListInteractor.swift in Sources */, 845B811528F43C350040CE84 /* Treasury.swift in Sources */, + 0C13DFD72AFA50A200E5F355 /* SwapIssueViewModelFactory.swift in Sources */, 0C7C886C2A9622F800DD96A1 /* StakingSelectedEntityViewModel.swift in Sources */, 84C3420B283187D800156569 /* BlockTimeEstimationService.swift in Sources */, EB376E61CD1C39AC148DE80C /* NftListViewController.swift in Sources */, + 0CCA245B2AC6917400AEF23D /* XcmV3.swift in Sources */, 84BAD213293AFCDA00C55C49 /* TokensManageTableViewCell.swift in Sources */, 84C5ADD02811E6FA006D7388 /* LinkCellView.swift in Sources */, EBDDDEE1BAB05E95DC720783 /* NftListViewLayout.swift in Sources */, @@ -21925,11 +22694,13 @@ B6DB30A8D1BF84158CAC635D /* OperationDetailsInteractor.swift in Sources */, C9931414951375760E5D1C57 /* OperationDetailsViewController.swift in Sources */, 84FBED0129277CD700FBEB83 /* EvmAssetBalanceUpdatingService.swift in Sources */, + 77740BC22AD69E3400E8C06F /* SwapMaxButtonView.swift in Sources */, 84ADA61029B9E2E800EB687E /* MultiExtrinsicRetryable.swift in Sources */, 67684F7576ED0252C1050CA5 /* OperationDetailsViewLayout.swift in Sources */, 77F189442A49974A00E8B933 /* UITextView+bind.swift in Sources */, D840B64C33EF47E723905378 /* OperationDetailsViewFactory.swift in Sources */, 84355CE628B507AD004E5C5E /* LedgerAccountAmount.swift in Sources */, + 77E304AD2AEC16B2006FD6F0 /* SwapPriceDifferenceViewModelFactoryProtocol.swift in Sources */, 841E5553282E35D000C8438F /* StakingParachainPresenter.swift in Sources */, 88F33F1329CC1ECD006125D5 /* Web3NameAddressesSelectionState.swift in Sources */, EB8FCA00BD20C63D578D6F80 /* TransferSetupProtocols.swift in Sources */, @@ -21969,8 +22740,10 @@ 6ECB27B386124F87382073FD /* DAppAddFavoriteProtocols.swift in Sources */, 84033057290FD8AB009C18E6 /* ReferendumsPersonalActivityView.swift in Sources */, 433A3C2B0D1E4BA5974D681B /* DAppAddFavoriteWireframe.swift in Sources */, + 77C9BCCC2ACD576B00022EA2 /* SwapAssetsOperationProtocols.swift in Sources */, F5CA222FA684AAC8B556E667 /* DAppAddFavoritePresenter.swift in Sources */, AC904E313DC15AE40C927946 /* DAppAddFavoriteInteractor.swift in Sources */, + 0CD352932ACAD7A500B3E446 /* AssetHubExtrinsicService.swift in Sources */, 006BEDBD2F98FF54DB993D8C /* DAppAddFavoriteViewController.swift in Sources */, 84FFE45D28620833002432BB /* XcmTransferResolutionService.swift in Sources */, D3F199376DAEBF380C5FFD9D /* DAppAddFavoriteViewLayout.swift in Sources */, @@ -21990,12 +22763,14 @@ 2B682D343F75EBEB8A1E65BD /* DAppAuthSettingsViewLayout.swift in Sources */, 148748ACAE23B7D15144015B /* DAppAuthSettingsViewFactory.swift in Sources */, B1F86CA723BB4D69C5EF989D /* ParaStkStakeSetupProtocols.swift in Sources */, + 771901AB2AE9581400D9C918 /* SwapConfirmViewModelFactory.swift in Sources */, 623474C49445578F030291B0 /* ParaStkStakeSetupWireframe.swift in Sources */, 8490110B29E5A491005D688B /* URIScanViewFactory.swift in Sources */, 840B3D672899BFD200DA1DA9 /* QRScannerViewSettings.swift in Sources */, 577918C3D4AA22D887F605B5 /* ParaStkStakeSetupPresenter.swift in Sources */, 7050A26051FE62DB06B695F1 /* ParaStkStakeSetupInteractor.swift in Sources */, 0CD1F4D100ED82D137AB9834 /* ParaStkStakeSetupViewController.swift in Sources */, + 77AAE2282AFC1167006872CC /* ChainModel+historyId.swift in Sources */, D9ECCCCF1449EFAFD0FA886E /* ParaStkStakeSetupViewLayout.swift in Sources */, 84981EEC29D4385F00948306 /* TransactionHistoryHybridFetcher.swift in Sources */, A714CEAF7A86292E8D679056 /* ParaStkStakeSetupViewFactory.swift in Sources */, @@ -22013,6 +22788,7 @@ 8217DCBEB74527D57AC82070 /* ParaStkStakeConfirmViewLayout.swift in Sources */, 06FD6F5999D57B27B29C8738 /* ParaStkStakeConfirmViewFactory.swift in Sources */, 0C9525E32A7AAB2A00BD724D /* StakingTimeModel.swift in Sources */, + 0CFA16192B0CE709007AF885 /* UtilityPallet.swift in Sources */, 8846F73129D6BE5000B8B776 /* Data+base58.swift in Sources */, 2BBA744323AA0BF6FE53C212 /* ParaStkSelectCollatorsProtocols.swift in Sources */, 880059E128EF0A5C00E87B9B /* VotingProgressView.swift in Sources */, @@ -22043,6 +22819,7 @@ BFC8C5A2C95D6EDF97D73732 /* ParaStkCollatorsSearchPresenter.swift in Sources */, 25993E2E536DE682E1DFC9AD /* ParaStkCollatorsSearchInteractor.swift in Sources */, 842643BF28785CFC0031B5B5 /* TuringStaking.swift in Sources */, + 0CB64E5E2B00AA8F008F268F /* GetTokenOptionsResult.swift in Sources */, 688F73AFD5FF20F77242B57E /* ParaStkCollatorsSearchViewController.swift in Sources */, 3BFD635E852E4D395025BEE8 /* ParaStkCollatorsSearchViewFactory.swift in Sources */, 88FB7DCB2950712E00784E08 /* AssetDetailsContainerViewController.swift in Sources */, @@ -22092,6 +22869,7 @@ 54D334605E9A7C71A4873CFC /* ParaStkRedeemWireframe.swift in Sources */, 413CCB7C7B22831147B8E815 /* ParaStkRedeemPresenter.swift in Sources */, 35F9157CAA182493B2F0E1D3 /* ParaStkRedeemInteractor.swift in Sources */, + 771901A22AE7E34D00D9C918 /* SwapBaseInteractor.swift in Sources */, C729BF3E60E6825AEED11383 /* ParaStkRedeemViewController.swift in Sources */, 844C3E752A091B9800C4305F /* WalletsChooseViewModelFactory.swift in Sources */, E477B09B47A3021EF1CE66F0 /* ParaStkRedeemViewLayout.swift in Sources */, @@ -22130,6 +22908,7 @@ 84770F25291F72D700852A33 /* ReferendumVotingInitData.swift in Sources */, 84E0C51E29CA40DA000B65C8 /* OperationContractCallModel.swift in Sources */, 846A835D28B8D09600D92892 /* LedgerMessageSheetViewFactory.swift in Sources */, + 774092002ACC1BE400172516 /* SwapAmountInput.swift in Sources */, 60FFEE5B386E82D70333BE80 /* CreateWatchOnlyPresenter.swift in Sources */, 842E9EA02A2DC23900759972 /* StakingDashboardBuilderProtocol.swift in Sources */, 454D41CC5C7CC2FDAB778026 /* CreateWatchOnlyInteractor.swift in Sources */, @@ -22138,6 +22917,7 @@ 88E74E8C29538C36008031A3 /* QRCodeInfo.swift in Sources */, 777BD86229F97322004969A2 /* ReferendumsFilter.swift in Sources */, 84282288289AC80600163031 /* MultiValueView+Factory.swift in Sources */, + 0C13DFDD2AFA82BA00E5F355 /* AmountInputViewModel+Slippage.swift in Sources */, 99570B581EF2EE8A1070442F /* CreateWatchOnlyViewFactory.swift in Sources */, 6003DF3EBB77510EFB70B4E4 /* MessageSheetProtocols.swift in Sources */, 0C13D31D2A82279D0054BB6F /* StartStakingDirectConfirmPresenter.swift in Sources */, @@ -22155,6 +22935,7 @@ 1812D5012A1765CB38D32A4A /* WalletsListPresenter.swift in Sources */, A265CC9857E951EB71E5E831 /* WalletsListInteractor.swift in Sources */, 28B4C94DBAF461CBF18B1B63 /* WalletsListViewController.swift in Sources */, + 77C9BCC42ACD570100022EA2 /* SwapAssetsOperationInteractor.swift in Sources */, 233CB11F486DE1953D977295 /* WalletsListViewLayout.swift in Sources */, 0C9525E52A7AF82B00BD724D /* DirectStakingMinStakeBuilder.swift in Sources */, FD43B68CFBD5C3497B446F53 /* ChangeWatchOnlyProtocols.swift in Sources */, @@ -22210,6 +22991,7 @@ 211725E26764530359F53A38 /* ParitySignerTxQrInteractor.swift in Sources */, 845B0817291902CF005785D3 /* Gov2LockStateFactory.swift in Sources */, 41DE96F778AE909978775438 /* ParitySignerTxQrViewController.swift in Sources */, + 0CD3A6802AEAC3C90059BBEC /* CompoundOperationWrapper+Add.swift in Sources */, 844C3E5C2A0615A200C4305F /* SettingsAccessoryTableViewCell.swift in Sources */, 0C9C643A2A8DF97E004DC078 /* StakingNPoolsError.swift in Sources */, 87F7556E02F6F5BB6F1B1AEA /* ParitySignerTxQrViewLayout.swift in Sources */, @@ -22273,12 +23055,14 @@ 84CEF28E29050A3300BA25BB /* DataValidationRunner+GovVote.swift in Sources */, 95EBC71EAE906B0DFA758AB8 /* LedgerTxConfirmProtocols.swift in Sources */, 992678FD5D3F9D39FFC2BB53 /* LedgerTxConfirmWireframe.swift in Sources */, + 0CB64E5C2B009DA9008F268F /* GetTokenOptionsModel.swift in Sources */, 84C1DBC529C27D9800F295A5 /* RewardCalculatorParamsServiceFactory.swift in Sources */, ED529AA4ED05E8847C4C067F /* LedgerTxConfirmPresenter.swift in Sources */, E488F3E052650FF525D41D63 /* LedgerTxConfirmInteractor.swift in Sources */, 75DAB313623E900EC475E215 /* LedgerTxConfirmViewFactory.swift in Sources */, 44D9F74D7851B874F2045E7E /* LedgerInstructionsProtocols.swift in Sources */, 84AC794A297FEAFF00E284DE /* GovernanceDelegateDetails.swift in Sources */, + 77E304B22AEFC0F3006FD6F0 /* SwapNetworkFeeSheetViewController.swift in Sources */, 84715783291132B400D7D003 /* GovernanceUnlockTableViewCell.swift in Sources */, 84A5915E292B3C3D00BCCF8F /* EvmTransactionBuilder+Transfer.swift in Sources */, CD4240B756F20C338A8B3589 /* LedgerInstructionsWireframe.swift in Sources */, @@ -22336,6 +23120,7 @@ C644308270C29AC6F90CFEA6 /* ReferendumDetailsWireframe.swift in Sources */, 7D2906130F25492872637EFC /* ReferendumDetailsPresenter.swift in Sources */, 5E3B1E6B9E94848B186FD4D1 /* ReferendumDetailsInteractor.swift in Sources */, + 77740BC02AD4A80D00E8C06F /* SwapInfoView.swift in Sources */, 488E4467895040EA85FDCC79 /* ReferendumDetailsViewController.swift in Sources */, 845B811D28F44A700040CE84 /* ReferendumActionLocal.swift in Sources */, 77E255692A16148000B644C3 /* StakingRewardsFilter.swift in Sources */, @@ -22416,7 +23201,9 @@ 5B652F1E0040F68F835A2F1D /* AssetDetailsViewLayout.swift in Sources */, E0710E487509797C12110D83 /* AssetDetailsViewFactory.swift in Sources */, 58F385F41D42CC96373EDA42 /* TokensManageProtocols.swift in Sources */, + 0C500B242B0511F400ABEE70 /* ExtrinsicProcessor+CustomFee.swift in Sources */, CA3C4729115D875D0C80A3E8 /* TokensManageWireframe.swift in Sources */, + 0CFA16132B0CD8A0007AF885 /* GovSpentAmountExtractor.swift in Sources */, 844C3E6B2A08C05A00C4305F /* DAppWalletAuthViewModelFactory.swift in Sources */, 88E5E2A7295D8FA1001B1D41 /* TitleIconViewModel+Hashable.swift in Sources */, 1772735F89EFA931DF7420AD /* TokensManagePresenter.swift in Sources */, @@ -22493,6 +23280,7 @@ 34D6FF85BEA25EFD1D15D460 /* InAppUpdatesInteractor.swift in Sources */, 0C1FE4F62A52F137003769E7 /* AssetSearchBuilderResult.swift in Sources */, 841E54A07ACD3AD160A1250A /* InAppUpdatesViewController.swift in Sources */, + 77C9BCD22ACD8AF400022EA2 /* SwapAssetsOperationViewController.swift in Sources */, DDA07514BEF3E2FD6EE1BB4E /* InAppUpdatesViewLayout.swift in Sources */, 32009DBB90D19ACD6D7B7A5C /* InAppUpdatesViewFactory.swift in Sources */, 77AB555D2AA24BA90058814E /* OperationDetailsPoolRewardView.swift in Sources */, @@ -22562,6 +23350,7 @@ 1232A714A96F937330FC0AFA /* GovernanceDelegateConfirmViewFactory.swift in Sources */, 4B1FA597B618713C75917816 /* GovernanceYourDelegationsProtocols.swift in Sources */, 15B079FA97C96327FD4A2E16 /* GovernanceYourDelegationsWireframe.swift in Sources */, + 77740BC42AD8145500E8C06F /* PercentInputView.swift in Sources */, 75249684C6F3EE4E553DABA1 /* GovernanceYourDelegationsPresenter.swift in Sources */, EB20C6B406155664B981BA94 /* GovernanceYourDelegationsInteractor.swift in Sources */, 59A0AF440ABAAA459EF7D993 /* GovernanceYourDelegationsViewController.swift in Sources */, @@ -22590,6 +23379,7 @@ AD877F7F77F9CB862DC7D5B3 /* DelegationReferendumVotersWireframe.swift in Sources */, 0DD3DB85B0E7FD5692F58787 /* DelegationReferendumVotersPresenter.swift in Sources */, 8A2486E62C3915CB6D1FDED8 /* DelegationReferendumVotersInteractor.swift in Sources */, + 77740BBE2AD4A7F500E8C06F /* SwapDetailsView.swift in Sources */, 0F073C8161B9852DEB1D40CD /* DelegationReferendumVotersViewController.swift in Sources */, D274117F06B12F955073D35B /* DelegationReferendumVotersViewLayout.swift in Sources */, E6AB0111B3E1297242D5DBDE /* DelegationReferendumVotersViewFactory.swift in Sources */, @@ -22603,6 +23393,7 @@ 8C68C4CFAF7CB9312C86D5B8 /* GovernanceDelegateSearchViewController.swift in Sources */, 77F9FB0B2A9D97A100820625 /* NominationPoolBondMoreSetupWireframe.swift in Sources */, F0C3DCA3CD4F850C16406716 /* GovernanceDelegateSearchViewFactory.swift in Sources */, + 0C13DFD52AFA4F1500E5F355 /* SwapIssueCheckParams.swift in Sources */, C98A02D4DEAC6E4CACB9E47E /* StakingRebagConfirmProtocols.swift in Sources */, B09F155D14D146377FB2952A /* StakingRebagConfirmWireframe.swift in Sources */, 840AE2E329C9A715008FF665 /* OptionStringCodable+Empty.swift in Sources */, @@ -22627,6 +23418,7 @@ CF2F3A0F0999D6D054CD33D2 /* ReferendumSearchProtocols.swift in Sources */, 0CE629D62AA9B5E200E250BD /* BalanceViewModel.swift in Sources */, 0C2F86982A728EE900593C01 /* NPoolsRewardEngineFactory.swift in Sources */, + 77E304AB2AEBB214006FD6F0 /* SlippageBounds.swift in Sources */, C5B07E59C0B00CAD1D0D2DFD /* ReferendumSearchWireframe.swift in Sources */, AB27EE4EE30A06D8E7B8EDB4 /* ReferendumSearchPresenter.swift in Sources */, 2F2ACE609F7423EDD0F06F30 /* ReferendumSearchViewController.swift in Sources */, @@ -22636,6 +23428,7 @@ 2EDE38E0F2E3494D16717A74 /* WalletConnectInteractor.swift in Sources */, EB11BF594D7E16A8885D47DD /* WalletConnectServiceFactory.swift in Sources */, C8171AF2893A4723F4F63E23 /* WalletConnectSessionsProtocols.swift in Sources */, + 0CA50CB12AFE6F15005668CD /* GetTokenOptionsProtocols.swift in Sources */, 0C59E8D52AA5FC96001E11F3 /* ExternalAssetBalanceMapper.swift in Sources */, 842E9EA42A2DC71000759972 /* StakingDashboardModel.swift in Sources */, AB5EA0348C8E8C40FCA9DC86 /* WalletConnectSessionsWireframe.swift in Sources */, @@ -22647,6 +23440,7 @@ 6DC454C4BA27C98987F5DC52 /* WalletConnectSessionsViewFactory.swift in Sources */, F332FA8C330A16C3894B6542 /* WalletConnectSessionDetailsProtocols.swift in Sources */, D0AD3C44BBFD6A9F9FDEC933 /* WalletConnectSessionDetailsWireframe.swift in Sources */, + 0CFA161C2B0CE851007AF885 /* Utility+Calls.swift in Sources */, 0A44D28DF4BCF56131752F35 /* WalletConnectSessionDetailsPresenter.swift in Sources */, F92E73C24AB577F37B35649E /* WalletConnectSessionDetailsInteractor.swift in Sources */, E06F3BD43E589BCE3904BBCB /* WalletConnectSessionDetailsViewController.swift in Sources */, @@ -22765,6 +23559,31 @@ EB877554208E91A80985F1E5 /* NPoolsRedeemViewController.swift in Sources */, 59D03D8CB4AE60DE53130729 /* NPoolsRedeemViewLayout.swift in Sources */, F3719F7C2AD0B75FC271DCE9 /* NPoolsRedeemViewFactory.swift in Sources */, + 92984DFE797C52644C084377 /* SwapSetupProtocols.swift in Sources */, + 5B02D050E6E067906A05B46B /* SwapSetupWireframe.swift in Sources */, + 3EE29545824B68594751769C /* SwapSetupPresenter.swift in Sources */, + 54813408A51B4AEBA3EED0A5 /* SwapSetupInteractor.swift in Sources */, + 8786222ADF4643BE7A6FBBEB /* SwapSetupViewController.swift in Sources */, + 350B8A18C9C91DF07D2E53C5 /* SwapSetupViewLayout.swift in Sources */, + 66531C7E2E0E99C89A89A35A /* SwapSetupViewFactory.swift in Sources */, + EB280B977471EA442D91395E /* SwapConfirmProtocols.swift in Sources */, + 232B205831DF28B772515DF2 /* SwapConfirmWireframe.swift in Sources */, + A0FB39F2C6192F57B2FAFC37 /* SwapConfirmPresenter.swift in Sources */, + 8960EC452EA6E7E9105F4762 /* SwapConfirmInteractor.swift in Sources */, + EA7BA01C6745ABE0E1AF7E1D /* SwapConfirmViewController.swift in Sources */, + C28A0BFC483095E132705C83 /* SwapConfirmViewLayout.swift in Sources */, + C61286217D7D54EC70E3F1AA /* SwapConfirmViewFactory.swift in Sources */, + 94A6747402550FB39D2E2BE7 /* SwapSlippageProtocols.swift in Sources */, + 2451E27286A176CDA2DC040D /* SwapSlippageWireframe.swift in Sources */, + 07D1F0F4FAE24BED8A1CF257 /* SwapSlippagePresenter.swift in Sources */, + 143F6C9044429A337265DF39 /* SwapSlippageViewController.swift in Sources */, + F9CEF01779F811AEEED06C43 /* SwapSlippageViewLayout.swift in Sources */, + 80603DA36CD481AE310CDFE1 /* SwapSlippageViewFactory.swift in Sources */, + F3EA441846781BE5D7F3D7AB /* TransferNetworkSelectionProtocols.swift in Sources */, + 66436210FD782DAF13814711 /* TransferNetworkSelectionPresenter.swift in Sources */, + 34E4B25CDFE0D7B5F4F18185 /* TransferNetworkSelectionInteractor.swift in Sources */, + 7A47B0731BC8B411CC01C3D1 /* TransferNetworkSelectionViewController.swift in Sources */, + 0BB2E3FF30B1700D321C526A /* TransferNetworkSelectionViewFactory.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -22895,6 +23714,7 @@ 84B66A1426FDF29A0038B963 /* WalletLocalSubscriptionFactoryStub.swift in Sources */, 887248082924F54900B0D2CC /* URL+Matchable.swift in Sources */, 8440F4A5295AB4E300CAFBF9 /* SecurityLayerTests.swift in Sources */, + 771901902AE2424B00D9C918 /* SwapsValidationTests.swift in Sources */, 84B7C718289BFA79001A3566 /* DAppOperationConfirmTests.swift in Sources */, 844D229E25EE79EA00C022F7 /* ExtrinsicServiceStub.swift in Sources */, 84F4A910255001D2000CF0A3 /* KeystoreExportWrapperTests.swift in Sources */, @@ -23593,6 +24413,8 @@ 843910CA253F7E6500E3C217 /* SubstrateDataModel.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 77AAE22B2AFCD7AA006872CC /* SubstrateDataModel21.xcdatamodel */, + 0CEB4ECF2AF148500048FD84 /* SubstrateDataModel20.xcdatamodel */, 0C59E8C72AA5C3F4001E11F3 /* SubstrateDataModel19.xcdatamodel */, 0CC2E55C2A6AAFFD004092E7 /* SubstrateDataModel18.xcdatamodel */, 0C04290B2A67A42A00C3583A /* SubstrateDataModel17.xcdatamodel */, @@ -23613,7 +24435,7 @@ 88787F0328DB3A7B00B115AB /* SubstrateDataModel2.xcdatamodel */, 843910CB253F7E6500E3C217 /* SubstrateDataModel.xcdatamodel */, ); - currentVersion = 0C59E8C72AA5C3F4001E11F3 /* SubstrateDataModel19.xcdatamodel */; + currentVersion = 77AAE22B2AFCD7AA006872CC /* SubstrateDataModel21.xcdatamodel */; path = SubstrateDataModel.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/novawallet/Assets.xcassets/colors/background/colorSegmentedBackgroundOnBlack.colorset/Contents.json b/novawallet/Assets.xcassets/colors/background/colorSegmentedBackgroundOnBlack.colorset/Contents.json new file mode 100644 index 0000000000..ddc7073407 --- /dev/null +++ b/novawallet/Assets.xcassets/colors/background/colorSegmentedBackgroundOnBlack.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "0xC7", + "green" : "0x9E", + "red" : "0x99" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/novawallet/Assets.xcassets/colors/border/colorBorderError.colorset/Contents.json b/novawallet/Assets.xcassets/colors/border/colorBorderError.colorset/Contents.json new file mode 100644 index 0000000000..67981ae6d0 --- /dev/null +++ b/novawallet/Assets.xcassets/colors/border/colorBorderError.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x50", + "green" : "0x34", + "red" : "0xE5" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/novawallet/Assets.xcassets/iconActionSwap.imageset/Contents.json b/novawallet/Assets.xcassets/iconActionSwap.imageset/Contents.json new file mode 100644 index 0000000000..96847b1a57 --- /dev/null +++ b/novawallet/Assets.xcassets/iconActionSwap.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "container-transaction-type.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/novawallet/Assets.xcassets/iconActionSwap.imageset/container-transaction-type.pdf b/novawallet/Assets.xcassets/iconActionSwap.imageset/container-transaction-type.pdf new file mode 100644 index 0000000000..53e799c5e2 Binary files /dev/null and b/novawallet/Assets.xcassets/iconActionSwap.imageset/container-transaction-type.pdf differ diff --git a/novawallet/Assets.xcassets/iconAddSwapAmount.imageset/Contents.json b/novawallet/Assets.xcassets/iconAddSwapAmount.imageset/Contents.json new file mode 100644 index 0000000000..9d63318b20 --- /dev/null +++ b/novawallet/Assets.xcassets/iconAddSwapAmount.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "container-token.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/novawallet/Assets.xcassets/iconAddSwapAmount.imageset/container-token.pdf b/novawallet/Assets.xcassets/iconAddSwapAmount.imageset/container-token.pdf new file mode 100644 index 0000000000..8dd529ed0b Binary files /dev/null and b/novawallet/Assets.xcassets/iconAddSwapAmount.imageset/container-token.pdf differ diff --git a/novawallet/Assets.xcassets/iconCrossChainTransfer.imageset/Contents.json b/novawallet/Assets.xcassets/iconCrossChainTransfer.imageset/Contents.json new file mode 100644 index 0000000000..531f8ab844 --- /dev/null +++ b/novawallet/Assets.xcassets/iconCrossChainTransfer.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "cross-chain.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/novawallet/Assets.xcassets/iconCrossChainTransfer.imageset/cross-chain.pdf b/novawallet/Assets.xcassets/iconCrossChainTransfer.imageset/cross-chain.pdf new file mode 100644 index 0000000000..a0293ea084 Binary files /dev/null and b/novawallet/Assets.xcassets/iconCrossChainTransfer.imageset/cross-chain.pdf differ diff --git a/novawallet/Assets.xcassets/iconForward.imageset/Contents.json b/novawallet/Assets.xcassets/iconForward.imageset/Contents.json new file mode 100644 index 0000000000..5a580ab3d4 --- /dev/null +++ b/novawallet/Assets.xcassets/iconForward.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "arrow-forward.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/novawallet/Assets.xcassets/iconForward.imageset/arrow-forward.pdf b/novawallet/Assets.xcassets/iconForward.imageset/arrow-forward.pdf new file mode 100644 index 0000000000..a2d45bb272 Binary files /dev/null and b/novawallet/Assets.xcassets/iconForward.imageset/arrow-forward.pdf differ diff --git a/novawallet/Assets.xcassets/iconIncomingTransfer.imageset/iconIncomingTransfer.pdf b/novawallet/Assets.xcassets/iconIncomingTransfer.imageset/iconIncomingTransfer.pdf index 06bbafad70..42864579b3 100644 Binary files a/novawallet/Assets.xcassets/iconIncomingTransfer.imageset/iconIncomingTransfer.pdf and b/novawallet/Assets.xcassets/iconIncomingTransfer.imageset/iconIncomingTransfer.pdf differ diff --git a/novawallet/Assets.xcassets/iconInfo.imageset/iconInfo.pdf b/novawallet/Assets.xcassets/iconInfo.imageset/iconInfo.pdf deleted file mode 100644 index a986d0f892..0000000000 Binary files a/novawallet/Assets.xcassets/iconInfo.imageset/iconInfo.pdf and /dev/null differ diff --git a/novawallet/Assets.xcassets/iconInfoAccent.imageset/Contents.json b/novawallet/Assets.xcassets/iconInfoAccent.imageset/Contents.json new file mode 100644 index 0000000000..f01eaf76da --- /dev/null +++ b/novawallet/Assets.xcassets/iconInfoAccent.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iconInfoAccent.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/novawallet/Assets.xcassets/iconInfoAccent.imageset/iconInfoAccent.pdf b/novawallet/Assets.xcassets/iconInfoAccent.imageset/iconInfoAccent.pdf new file mode 100644 index 0000000000..a83adb0b77 Binary files /dev/null and b/novawallet/Assets.xcassets/iconInfoAccent.imageset/iconInfoAccent.pdf differ diff --git a/novawallet/Assets.xcassets/iconInfoFilled.imageset/Contents.json b/novawallet/Assets.xcassets/iconInfoFilled.imageset/Contents.json index e059f3541c..1c3dd27f74 100644 --- a/novawallet/Assets.xcassets/iconInfoFilled.imageset/Contents.json +++ b/novawallet/Assets.xcassets/iconInfoFilled.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "iconInfoFilled.pdf", + "filename" : "new-info-icon.pdf", "idiom" : "universal" } ], @@ -10,6 +10,6 @@ "version" : 1 }, "properties" : { - "template-rendering-intent" : "template" + "template-rendering-intent" : "original" } } diff --git a/novawallet/Assets.xcassets/iconInfoFilled.imageset/iconInfoFilled.pdf b/novawallet/Assets.xcassets/iconInfoFilled.imageset/iconInfoFilled.pdf deleted file mode 100644 index 2293e40b1b..0000000000 Binary files a/novawallet/Assets.xcassets/iconInfoFilled.imageset/iconInfoFilled.pdf and /dev/null differ diff --git a/novawallet/Assets.xcassets/iconInfoFilled.imageset/new-info-icon.pdf b/novawallet/Assets.xcassets/iconInfoFilled.imageset/new-info-icon.pdf new file mode 100644 index 0000000000..aba8e7fbd0 Binary files /dev/null and b/novawallet/Assets.xcassets/iconInfoFilled.imageset/new-info-icon.pdf differ diff --git a/novawallet/Assets.xcassets/iconOptions.imageset/Contents.json b/novawallet/Assets.xcassets/iconOptions.imageset/Contents.json index f0ea4c388b..22c5f4d411 100644 --- a/novawallet/Assets.xcassets/iconOptions.imageset/Contents.json +++ b/novawallet/Assets.xcassets/iconOptions.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "options-nav-bar.svg", + "filename" : "options.pdf", "idiom" : "universal" } ], diff --git a/novawallet/Assets.xcassets/iconOptions.imageset/options-nav-bar.svg b/novawallet/Assets.xcassets/iconOptions.imageset/options-nav-bar.svg deleted file mode 100644 index 947fcf23dd..0000000000 --- a/novawallet/Assets.xcassets/iconOptions.imageset/options-nav-bar.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/novawallet/Assets.xcassets/iconOptions.imageset/options.pdf b/novawallet/Assets.xcassets/iconOptions.imageset/options.pdf new file mode 100644 index 0000000000..30156b9a2b Binary files /dev/null and b/novawallet/Assets.xcassets/iconOptions.imageset/options.pdf differ diff --git a/novawallet/Assets.xcassets/iconOutgoingTransfer.imageset/iconOutgoingTransfer.pdf b/novawallet/Assets.xcassets/iconOutgoingTransfer.imageset/iconOutgoingTransfer.pdf index 3c90142fc4..029e46de3d 100644 Binary files a/novawallet/Assets.xcassets/iconOutgoingTransfer.imageset/iconOutgoingTransfer.pdf and b/novawallet/Assets.xcassets/iconOutgoingTransfer.imageset/iconOutgoingTransfer.pdf differ diff --git a/novawallet/Assets.xcassets/iconPencilEdit.imageset/Contents.json b/novawallet/Assets.xcassets/iconPencilEdit.imageset/Contents.json new file mode 100644 index 0000000000..3d542df31b --- /dev/null +++ b/novawallet/Assets.xcassets/iconPencilEdit.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iconPencilEdit.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/novawallet/Assets.xcassets/iconPencilEdit.imageset/iconPencilEdit.pdf b/novawallet/Assets.xcassets/iconPencilEdit.imageset/iconPencilEdit.pdf new file mode 100644 index 0000000000..54973c3f86 Binary files /dev/null and b/novawallet/Assets.xcassets/iconPencilEdit.imageset/iconPencilEdit.pdf differ diff --git a/novawallet/Assets.xcassets/iconRewardOperation.imageset/iconRewardOperation.pdf b/novawallet/Assets.xcassets/iconRewardOperation.imageset/iconRewardOperation.pdf index 8253361fa3..c4786e39e6 100644 Binary files a/novawallet/Assets.xcassets/iconRewardOperation.imageset/iconRewardOperation.pdf and b/novawallet/Assets.xcassets/iconRewardOperation.imageset/iconRewardOperation.pdf differ diff --git a/novawallet/Assets.xcassets/iconSwap.imageset/Contents.json b/novawallet/Assets.xcassets/iconSwap.imageset/Contents.json new file mode 100644 index 0000000000..9c2b93ff02 --- /dev/null +++ b/novawallet/Assets.xcassets/iconSwap.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iconSwapOnDetails.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/novawallet/Assets.xcassets/iconSwap.imageset/iconSwapOnDetails.pdf b/novawallet/Assets.xcassets/iconSwap.imageset/iconSwapOnDetails.pdf new file mode 100644 index 0000000000..2210e25dff Binary files /dev/null and b/novawallet/Assets.xcassets/iconSwap.imageset/iconSwapOnDetails.pdf differ diff --git a/novawallet/Assets.xcassets/iconInfo.imageset/Contents.json b/novawallet/Assets.xcassets/iconWiki.imageset/Contents.json similarity index 77% rename from novawallet/Assets.xcassets/iconInfo.imageset/Contents.json rename to novawallet/Assets.xcassets/iconWiki.imageset/Contents.json index 282eb33b72..72f6443dee 100644 --- a/novawallet/Assets.xcassets/iconInfo.imageset/Contents.json +++ b/novawallet/Assets.xcassets/iconWiki.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "iconInfo.pdf", + "filename" : "nova-wiki.pdf", "idiom" : "universal" } ], diff --git a/novawallet/Assets.xcassets/iconWiki.imageset/nova-wiki.pdf b/novawallet/Assets.xcassets/iconWiki.imageset/nova-wiki.pdf new file mode 100644 index 0000000000..71c02bf7a5 Binary files /dev/null and b/novawallet/Assets.xcassets/iconWiki.imageset/nova-wiki.pdf differ diff --git a/novawallet/Common/Configs/ApplicationConfigs.swift b/novawallet/Common/Configs/ApplicationConfigs.swift index 3e3b6bdb71..5a46e3b664 100644 --- a/novawallet/Common/Configs/ApplicationConfigs.swift +++ b/novawallet/Common/Configs/ApplicationConfigs.swift @@ -38,6 +38,7 @@ protocol ApplicationConfigProtocol { var inAppUpdatesEntrypointURL: URL { get } var inAppUpdatesChangelogsURL: URL { get } var slip44URL: URL { get } + var wikiURL: URL { get } } final class ApplicationConfig { @@ -129,9 +130,9 @@ extension ApplicationConfig: ApplicationConfigProtocol { var chainListURL: URL { #if F_RELEASE - URL(string: "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v15/chains.json")! + URL(string: "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v16/chains.json")! #else - URL(string: "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v15/chains_dev.json")! + URL(string: "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v16/chains_dev.json")! #endif } @@ -252,5 +253,9 @@ extension ApplicationConfig: ApplicationConfigProtocol { URL(string: "https://raw.githubusercontent.com/novasamatech/nova-utils/master/assets/slip44.json")! } + var wikiURL: URL { + URL(string: "https://docs.novawallet.io/nova-wallet-wiki")! + } + // swiftlint:enable line_length } diff --git a/novawallet/Common/DataProvider/GeneralStorageSubscriptionFactory.swift b/novawallet/Common/DataProvider/GeneralStorageSubscriptionFactory.swift index cf7a845552..7b993cd8cd 100644 --- a/novawallet/Common/DataProvider/GeneralStorageSubscriptionFactory.swift +++ b/novawallet/Common/DataProvider/GeneralStorageSubscriptionFactory.swift @@ -4,6 +4,11 @@ import SubstrateSdk protocol GeneralStorageSubscriptionFactoryProtocol { func getBlockNumberProvider(for chainId: ChainModel.Id) throws -> AnyDataProvider + + func getAccountInfoProvider( + for accountId: AccountId, + chainId: ChainModel.Id + ) throws -> AnyDataProvider } final class GeneralStorageSubscriptionFactory: SubstrateLocalSubscriptionFactory, @@ -19,4 +24,24 @@ final class GeneralStorageSubscriptionFactory: SubstrateLocalSubscriptionFactory shouldUseFallback: false ) } + + func getAccountInfoProvider( + for accountId: AccountId, + chainId: ChainModel.Id + ) throws -> AnyDataProvider { + let codingPath = StorageCodingPath.account + + let localKey = try LocalStorageKeyFactory().createFromStoragePath( + codingPath, + accountId: accountId, + chainId: chainId + ) + + return try getDataProvider( + for: localKey, + chainId: chainId, + storageCodingPath: codingPath, + shouldUseFallback: false + ) + } } diff --git a/novawallet/Common/DataProvider/Subscription/DecodedProviderTypes.swift b/novawallet/Common/DataProvider/Subscription/DecodedProviderTypes.swift index 84fa10d1b9..b2adc18c7b 100644 --- a/novawallet/Common/DataProvider/Subscription/DecodedProviderTypes.swift +++ b/novawallet/Common/DataProvider/Subscription/DecodedProviderTypes.swift @@ -12,6 +12,7 @@ typealias DecodedActiveEra = ChainStorageDecodedItem typealias DecodedEraIndex = ChainStorageDecodedItem> typealias DecodedPayee = ChainStorageDecodedItem typealias DecodedBlockNumber = ChainStorageDecodedItem> +typealias DecodedAccountInfo = ChainStorageDecodedItem typealias DecodedCrowdloanFunds = ChainStorageDecodedItem typealias DecodedBagListNode = ChainStorageDecodedItem typealias DecodedPoolMember = ChainStorageDecodedItem diff --git a/novawallet/Common/DataProvider/Subscription/GeneralLocalStorageHandler.swift b/novawallet/Common/DataProvider/Subscription/GeneralLocalStorageHandler.swift index a85102c7e4..10d3c2ae30 100644 --- a/novawallet/Common/DataProvider/Subscription/GeneralLocalStorageHandler.swift +++ b/novawallet/Common/DataProvider/Subscription/GeneralLocalStorageHandler.swift @@ -5,6 +5,12 @@ protocol GeneralLocalStorageHandler { result: Result, chainId: ChainModel.Id ) + + func handleAccountInfo( + result: Result, + accountId: AccountId, + chainId: ChainModel.Id + ) } extension GeneralLocalStorageHandler { @@ -12,4 +18,10 @@ extension GeneralLocalStorageHandler { result _: Result, chainId _: ChainModel.Id ) {} + + func handleAccountInfo( + result _: Result, + accountId _: AccountId, + chainId _: ChainModel.Id + ) {} } diff --git a/novawallet/Common/DataProvider/Subscription/GeneralLocalStorageSubscriber.swift b/novawallet/Common/DataProvider/Subscription/GeneralLocalStorageSubscriber.swift index 57abee453b..79541f5602 100644 --- a/novawallet/Common/DataProvider/Subscription/GeneralLocalStorageSubscriber.swift +++ b/novawallet/Common/DataProvider/Subscription/GeneralLocalStorageSubscriber.swift @@ -1,7 +1,7 @@ import Foundation import RobinHood -protocol GeneralLocalStorageSubscriber where Self: AnyObject { +protocol GeneralLocalStorageSubscriber: LocalStorageProviderObserving { var generalLocalSubscriptionFactory: GeneralStorageSubscriptionFactoryProtocol { get } var generalLocalSubscriptionHandler: GeneralLocalStorageHandler { get } @@ -9,6 +9,11 @@ protocol GeneralLocalStorageSubscriber where Self: AnyObject { func subscribeToBlockNumber( for chainId: ChainModel.Id ) -> AnyDataProvider? + + func subscribeAccountInfo( + for accountId: AccountId, + chainId: ChainModel.Id + ) -> AnyDataProvider? } extension GeneralLocalStorageSubscriber { @@ -51,6 +56,39 @@ extension GeneralLocalStorageSubscriber { return blockNumberProvider } + + func subscribeAccountInfo( + for accountId: AccountId, + chainId: ChainModel.Id + ) -> AnyDataProvider? { + guard + let provider = try? generalLocalSubscriptionFactory.getAccountInfoProvider( + for: accountId, + chainId: chainId + ) else { + return nil + } + + addDataProviderObserver( + for: provider, + updateClosure: { [weak self] accountInfo in + self?.generalLocalSubscriptionHandler.handleAccountInfo( + result: .success(accountInfo), + accountId: accountId, + chainId: chainId + ) + }, + failureClosure: { [weak self] error in + self?.generalLocalSubscriptionHandler.handleAccountInfo( + result: .failure(error), + accountId: accountId, + chainId: chainId + ) + } + ) + + return provider + } } extension GeneralLocalStorageSubscriber where Self: GeneralLocalStorageHandler { diff --git a/novawallet/Common/Extension/Error/Optional+Result.swift b/novawallet/Common/Extension/Error/Optional+Result.swift new file mode 100644 index 0000000000..82bb3b12d8 --- /dev/null +++ b/novawallet/Common/Extension/Error/Optional+Result.swift @@ -0,0 +1,12 @@ +import Foundation + +extension Optional { + func hasError() -> Bool where Wrapped == Result { + switch self { + case .success, .none: + return false + case .failure: + return true + } + } +} diff --git a/novawallet/Common/Extension/Foundation/BigInt+Decimal.swift b/novawallet/Common/Extension/Foundation/BigInt+Decimal.swift index 34df8de764..1837469cc2 100644 --- a/novawallet/Common/Extension/Foundation/BigInt+Decimal.swift +++ b/novawallet/Common/Extension/Foundation/BigInt+Decimal.swift @@ -9,3 +9,12 @@ extension BigUInt { ) ?? 0 } } + +extension Optional where Wrapped == BigUInt { + func decimalOrZero(precision: UInt16) -> Decimal { + guard let self = self, self != 0 else { + return 0 + } + return self.decimal(precision: precision) + } +} diff --git a/novawallet/Common/Extension/Foundation/BigUInt+Operation.swift b/novawallet/Common/Extension/Foundation/BigUInt+Operation.swift new file mode 100644 index 0000000000..f91e651e1c --- /dev/null +++ b/novawallet/Common/Extension/Foundation/BigUInt+Operation.swift @@ -0,0 +1,8 @@ +import Foundation +import BigInt + +extension BigUInt { + func saturatingSub(_ value: BigUInt) -> BigUInt { + self > value ? self - value : 0 + } +} diff --git a/novawallet/Common/Extension/Foundation/Decimal+Conversion.swift b/novawallet/Common/Extension/Foundation/Decimal+Conversion.swift index 3b30f2b135..ccf5704b6b 100644 --- a/novawallet/Common/Extension/Foundation/Decimal+Conversion.swift +++ b/novawallet/Common/Extension/Foundation/Decimal+Conversion.swift @@ -1,5 +1,6 @@ import Foundation import BigInt +import SubstrateSdk extension Decimal { static func fromSubstratePercent(value: UInt8) -> Decimal? { @@ -23,4 +24,20 @@ extension Decimal { return rounded } + + static func rateFromSubstrate( + amount1: BigUInt, + amount2: BigUInt, + precision1: Int16, + precision2: Int16 + ) -> Decimal? { + guard + let decimal1 = fromSubstrateAmount(amount1, precision: precision1), + let decimal2 = fromSubstrateAmount(amount2, precision: precision2), + decimal2 > 0 else { + return nil + } + + return decimal2 / decimal1 + } } diff --git a/novawallet/Common/Extension/Foundation/Decimal+Percents.swift b/novawallet/Common/Extension/Foundation/Decimal+Percents.swift new file mode 100644 index 0000000000..de75fa66d8 --- /dev/null +++ b/novawallet/Common/Extension/Foundation/Decimal+Percents.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Decimal { + func fromFractionToPercents() -> Decimal { + self * 100 + } + + func fromPercentsToFraction() -> Decimal { + self / 100 + } +} diff --git a/novawallet/Common/Extension/Foundation/Predicate/NSPredicate+Transaction.swift b/novawallet/Common/Extension/Foundation/Predicate/NSPredicate+Transaction.swift index 4fdc503341..10ef76dd0f 100644 --- a/novawallet/Common/Extension/Foundation/Predicate/NSPredicate+Transaction.swift +++ b/novawallet/Common/Extension/Foundation/Predicate/NSPredicate+Transaction.swift @@ -60,11 +60,13 @@ extension NSPredicate { let receiverPredicate = filterTransactionsByReceiver(address: address) let chainPredicate = filterTransactionsByChainId(chainId) let assetPredicate = filterTransactionsByAssetId(assetId) + let swapPredicate = filterSwapTransactionsByAssetId(assetId) let orPredicates = [senderPredicate, receiverPredicate] + let assetsAndSwapsPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [assetPredicate, swapPredicate]) let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ chainPredicate, - assetPredicate, + assetsAndSwapsPredicate, NSCompoundPredicate(orPredicateWithSubpredicates: orPredicates) ]) @@ -109,6 +111,7 @@ extension NSPredicate { let receiverPredicate = filterTransactionsByReceiver(address: address) let chainPredicate = filterTransactionsByChainId(chainId) let assetPredicate = filterTransactionsByAssetId(utilityAssetId) + let swapPredicate = filterSwapTransactionsByAssetId(utilityAssetId) let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ chainPredicate, @@ -118,6 +121,10 @@ extension NSPredicate { NSCompoundPredicate(andPredicateWithSubpredicates: [ assetPredicate, receiverPredicate + ]), + NSCompoundPredicate(andPredicateWithSubpredicates: [ + swapPredicate, + receiverPredicate ]) ] ) @@ -159,6 +166,20 @@ extension NSPredicate { NSPredicate(format: "%K == %d", #keyPath(CDTransactionItem.source), source.rawValue) } + static func filterSwapTransactionsByAssetId(_ assetId: UInt32) -> NSPredicate { + let assetIdIn = NSPredicate( + format: "%K == %d", #keyPath(CDTransactionItem.swap.assetIdIn), + Int32(bitPattern: assetId) + ) + + let assetIdOut = NSPredicate( + format: "%K == %d", #keyPath(CDTransactionItem.swap.assetIdOut), + Int32(bitPattern: assetId) + ) + + return NSCompoundPredicate(orPredicateWithSubpredicates: [assetIdIn, assetIdOut]) + } + static func filterTransactionsByType(_ type: WalletHistoryFilter) -> NSPredicate { var orPredicates: [NSPredicate] = [] @@ -174,6 +195,10 @@ extension NSPredicate { orPredicates.append(filterExtrinsicTransactions()) } + if type.contains(.swaps) { + orPredicates.append(filterSwapTransactions()) + } + return NSCompoundPredicate(orPredicateWithSubpredicates: orPredicates) } @@ -196,6 +221,10 @@ extension NSPredicate { return NSCompoundPredicate(orPredicateWithSubpredicates: predicates) } + static func filterSwapTransactions() -> NSPredicate { + NSPredicate(format: "%K != nil", #keyPath(CDTransactionItem.swap)) + } + static func filterRewardOrSlashTransactions() -> NSPredicate { let paths = [CallCodingPath.reward, CallCodingPath.slash] diff --git a/novawallet/Common/Extension/Foundation/String+Helpers.swift b/novawallet/Common/Extension/Foundation/String+Helpers.swift index 49e05dcf6d..d5b78d1a0a 100644 --- a/novawallet/Common/Extension/Foundation/String+Helpers.swift +++ b/novawallet/Common/Extension/Foundation/String+Helpers.swift @@ -37,6 +37,22 @@ extension String { return self } } + + func inParenthesis() -> String { + guard !isEmpty else { + return "" + } + + return "(\(self))" + } + + func estimatedEqual(to other: String) -> String { + "\(self) ≈ \(other)" + } + + func approximately() -> String { + "~\(self)" + } } extension Optional where Wrapped == String { diff --git a/novawallet/Common/Extension/Model/TransactionHistoryItem+Subscription.swift b/novawallet/Common/Extension/Model/TransactionHistoryItem+Subscription.swift index 8ebc4835ac..f43fb73d81 100644 --- a/novawallet/Common/Extension/Model/TransactionHistoryItem+Subscription.swift +++ b/novawallet/Common/Extension/Model/TransactionHistoryItem+Subscription.swift @@ -55,14 +55,29 @@ extension TransactionHistoryItem { txHash: txHash, timestamp: timestamp, fee: maybeFee, + feeAssetId: extrinsic.feeAssetId, blockNumber: result.blockNumber, txIndex: result.txIndex, callPath: result.processingResult.callPath, - call: encodedCall + call: encodedCall, + swap: createSwapIfNeeded(from: extrinsic) ) } catch { return nil } } + + private static func createSwapIfNeeded(from subscription: ExtrinsicProcessingResult) -> SwapHistoryData? { + guard let remoteSwap = subscription.swap else { + return nil + } + + return .init( + amountIn: String(remoteSwap.amountIn), + assetIdIn: remoteSwap.assetIdIn, + amountOut: String(remoteSwap.amountOut), + assetIdOut: remoteSwap.assetIdOut + ) + } } diff --git a/novawallet/Common/Extension/Operation/CompoundOperationWrapper+Add.swift b/novawallet/Common/Extension/Operation/CompoundOperationWrapper+Add.swift new file mode 100644 index 0000000000..afb390cf14 --- /dev/null +++ b/novawallet/Common/Extension/Operation/CompoundOperationWrapper+Add.swift @@ -0,0 +1,12 @@ +import Foundation +import RobinHood + +extension CompoundOperationWrapper { + func insertingHead(operations: [Operation]) -> CompoundOperationWrapper { + .init(targetOperation: targetOperation, dependencies: operations + dependencies) + } + + func insertingTail(operation: BaseOperation) -> CompoundOperationWrapper { + .init(targetOperation: operation, dependencies: allOperations) + } +} diff --git a/novawallet/Common/Extension/Storage/CDTransactionHistoryItem+CoreDataDecodable.swift b/novawallet/Common/Extension/Storage/CDTransactionHistoryItem+CoreDataDecodable.swift index ed76f32f8a..3302a22983 100644 --- a/novawallet/Common/Extension/Storage/CDTransactionHistoryItem+CoreDataDecodable.swift +++ b/novawallet/Common/Extension/Storage/CDTransactionHistoryItem+CoreDataDecodable.swift @@ -3,7 +3,7 @@ import RobinHood import CoreData extension CDTransactionItem: CoreDataCodable { - public func populate(from decoder: Decoder, using _: NSManagedObjectContext) throws { + public func populate(from decoder: Decoder, using context: NSManagedObjectContext) throws { let container = try decoder.container(keyedBy: TransactionHistoryItem.CodingKeys.self) let identifier = try container.decode(String.self, forKey: .identifier) @@ -28,7 +28,11 @@ extension CDTransactionItem: CoreDataCodable { if let fee = try container.decodeIfPresent(String.self, forKey: .fee) { self.fee = fee } - + if let feeAssetId = try container.decodeIfPresent(UInt32.self, forKey: .feeAssetId) { + self.feeAssetId = NSNumber(value: feeAssetId) + } else { + feeAssetId = nil + } let callPath = try container.decode(CallCodingPath.self, forKey: .callPath) callName = callPath.callName moduleName = callPath.moduleName @@ -46,11 +50,28 @@ extension CDTransactionItem: CoreDataCodable { } else { txIndex = nil } + if let swapContainer = try? container.nestedContainer(keyedBy: SwapHistoryData.CodingKeys.self, forKey: .swap) { + if swap == nil { + let newSwap = CDTransactionSwapItem(context: context) + newSwap.transaction = self + swap = newSwap + } + swap?.amountIn = try swapContainer.decode(String.self, forKey: .amountIn) + swap?.amountOut = try swapContainer.decode(String.self, forKey: .amountOut) + + let assetIdIn = try swapContainer.decodeIfPresent(UInt32.self, forKey: .assetIdIn) + swap?.assetIdIn = assetIdIn.map { NSNumber(value: Int32(bitPattern: $0)) } + + let assetIdOut = try swapContainer.decodeIfPresent(UInt32.self, forKey: .assetIdOut) + swap?.assetIdOut = assetIdOut.map { NSNumber(value: Int32(bitPattern: $0)) } + } } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: TransactionHistoryItem.CodingKeys.self) + let feeAssetId = feeAssetId.map { UInt32(bitPattern: $0.int32Value) } + try container.encodeIfPresent(identifier, forKey: .identifier) try container.encodeIfPresent(TransactionHistoryItemSource(rawValue: source), forKey: .source) try container.encodeIfPresent(chainId, forKey: .chainId) @@ -62,6 +83,7 @@ extension CDTransactionItem: CoreDataCodable { try container.encodeIfPresent(status, forKey: .status) try container.encodeIfPresent(timestamp, forKey: .timestamp) try container.encodeIfPresent(fee, forKey: .fee) + try container.encodeIfPresent(feeAssetId, forKey: .feeAssetId) try container.encodeIfPresent(blockNumber?.uint64Value, forKey: .blockNumber) try container.encodeIfPresent(txIndex?.int16Value, forKey: .txIndex) @@ -71,5 +93,20 @@ extension CDTransactionItem: CoreDataCodable { } try container.encodeIfPresent(call, forKey: .call) + + if let swap = swap { + var nestedSwap = container.nestedContainer(keyedBy: SwapHistoryData.CodingKeys.self, forKey: .swap) + try nestedSwap.encode(swap.amountIn, forKey: .amountIn) + try nestedSwap.encode(swap.amountOut, forKey: .amountOut) + try nestedSwap.encodeIfPresent( + swap.assetIdIn.map { UInt32(bitPattern: $0.int32Value) }, + forKey: .assetIdIn + ) + + try nestedSwap.encodeIfPresent( + swap.assetIdOut.map { UInt32(bitPattern: $0.int32Value) }, + forKey: .assetIdOut + ) + } } } diff --git a/novawallet/Common/Extension/UIKit/Style/RoundedView+Style.swift b/novawallet/Common/Extension/UIKit/Style/RoundedView+Style.swift index dc1ba9e35e..150cb0a62f 100644 --- a/novawallet/Common/Extension/UIKit/Style/RoundedView+Style.swift +++ b/novawallet/Common/Extension/UIKit/Style/RoundedView+Style.swift @@ -213,6 +213,16 @@ extension RoundedView.Style { rounding: .init(radius: 12, corners: .allCorners) ) + static let strokeOnError = RoundedView.Style( + shadowOpacity: 0, + strokeWidth: 0, + strokeColor: R.color.colorBorderError(), + highlightedStrokeColor: R.color.colorBorderError(), + fillColor: R.color.colorInputBackground()!, + highlightedFillColor: R.color.colorInputBackground()!, + rounding: .init(radius: 12, corners: .allCorners) + ) + static let clear = RoundedView.Style( shadowOpacity: 0, strokeWidth: 0, diff --git a/novawallet/Common/Extension/UIKit/Style/UILabel+Style.swift b/novawallet/Common/Extension/UIKit/Style/UILabel+Style.swift index f5f33d2b16..5c515796f4 100644 --- a/novawallet/Common/Extension/UIKit/Style/UILabel+Style.swift +++ b/novawallet/Common/Extension/UIKit/Style/UILabel+Style.swift @@ -106,6 +106,11 @@ extension UILabel.Style { font: .caption1 ) + static let caption1Negative = UILabel.Style( + textColor: R.color.colorTextNegative(), + font: .caption1 + ) + static let caption2Secondary = UILabel.Style( textColor: R.color.colorTextSecondary(), font: .caption2 diff --git a/novawallet/Common/Extension/UIKit/UIView+Separator.swift b/novawallet/Common/Extension/UIKit/UIView+Separator.swift index 6e7a0b4384..0c08e57077 100644 --- a/novawallet/Common/Extension/UIKit/UIView+Separator.swift +++ b/novawallet/Common/Extension/UIKit/UIView+Separator.swift @@ -1,4 +1,5 @@ import UIKit +import SnapKit extension UIView { static func createSeparator(color: UIColor? = R.color.colorDivider()) -> UIView { @@ -6,4 +7,19 @@ extension UIView { view.backgroundColor = color return view } + + func addBottomSeparator( + _ height: CGFloat = 1, + color: UIColor = R.color.colorDivider()!, + horizontalSpace: CGFloat = 0 + ) { + let separator = UIView.createSeparator(color: color) + addSubview(separator) + + separator.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(horizontalSpace) + $0.bottom.equalToSuperview() + $0.height.equalTo(height) + } + } } diff --git a/novawallet/Common/Helpers/CancellableCallHelper.swift b/novawallet/Common/Helpers/CancellableCallHelper.swift new file mode 100644 index 0000000000..722ca1fd1c --- /dev/null +++ b/novawallet/Common/Helpers/CancellableCallHelper.swift @@ -0,0 +1,81 @@ +import Foundation +import RobinHood + +final class CancellableCallStore { + private var cancellableCall: CancellableCall? + + func store(call: CancellableCall) { + cancellableCall = call + } + + func clear() { + cancellableCall = nil + } + + func cancel() { + let copy = cancellableCall + cancellableCall = nil + copy?.cancel() + } + + func clearIfMatches(call: CancellableCall) -> Bool { + guard matches(call: call) else { + return false + } + + cancellableCall = nil + + return true + } + + func matches(call: CancellableCall) -> Bool { + cancellableCall === call + } +} + +func execute( + wrapper: CompoundOperationWrapper, + inOperationQueue operationQueue: OperationQueue, + runningCallbackIn callbackQueue: DispatchQueue?, + callbackClosure: @escaping (Result) -> Void +) { + wrapper.targetOperation.completionBlock = { + dispatchInQueueWhenPossible(callbackQueue) { + do { + let value = try wrapper.targetOperation.extractNoCancellableResultData() + callbackClosure(.success(value)) + } catch { + callbackClosure(.failure(error)) + } + } + } + + operationQueue.addOperations(wrapper.allOperations, waitUntilFinished: false) +} + +func executeCancellable( + wrapper: CompoundOperationWrapper, + inOperationQueue operationQueue: OperationQueue, + backingCallIn callStore: CancellableCallStore, + runningCallbackIn callbackQueue: DispatchQueue?, + callbackClosure: @escaping (Result) -> Void +) { + wrapper.targetOperation.completionBlock = { + dispatchInQueueWhenPossible(callbackQueue) { + guard callStore.clearIfMatches(call: wrapper) else { + return + } + + do { + let value = try wrapper.targetOperation.extractNoCancellableResultData() + callbackClosure(.success(value)) + } catch { + callbackClosure(.failure(error)) + } + } + } + + callStore.store(call: wrapper) + + operationQueue.addOperations(wrapper.allOperations, waitUntilFinished: false) +} diff --git a/novawallet/Common/Helpers/SubmoduleNavigationStrategy.swift b/novawallet/Common/Helpers/SubmoduleNavigationStrategy.swift new file mode 100644 index 0000000000..4eaaa5805a --- /dev/null +++ b/novawallet/Common/Helpers/SubmoduleNavigationStrategy.swift @@ -0,0 +1,22 @@ +import Foundation + +enum SubmoduleNavigationStrategy { + typealias DismissCompletion = () -> Void + typealias DismissStart = (DismissCompletion?) -> Void + typealias Callback = () -> Void + + case callbackBeforeDismissal + case callbackAfterDismissal + + func applyStrategy(for dismissStart: DismissStart, callback: @escaping Callback) { + switch self { + case .callbackBeforeDismissal: + callback() + dismissStart(nil) + case .callbackAfterDismissal: + dismissStart { + callback() + } + } + } +} diff --git a/novawallet/Common/Helpers/TransactionHistory/ChainModel+historyId.swift b/novawallet/Common/Helpers/TransactionHistory/ChainModel+historyId.swift new file mode 100644 index 0000000000..c4dd01af01 --- /dev/null +++ b/novawallet/Common/Helpers/TransactionHistory/ChainModel+historyId.swift @@ -0,0 +1,12 @@ +extension ChainModel { + func asset(byHistoryAssetId assetId: String?) -> AssetModel? { + guard let assetId = assetId else { + return nil + } + return assets.first { asset in + let assetMapper = CustomAssetMapper(type: asset.type, typeExtras: asset.typeExtras) + let historyAssetId = try? assetMapper.historyAssetId() + return historyAssetId == assetId + } + } +} diff --git a/novawallet/Common/Helpers/TransactionHistory/PriceHistoryCalculatorFactory.swift b/novawallet/Common/Helpers/TransactionHistory/PriceHistoryCalculatorFactory.swift new file mode 100644 index 0000000000..3dbbb60b14 --- /dev/null +++ b/novawallet/Common/Helpers/TransactionHistory/PriceHistoryCalculatorFactory.swift @@ -0,0 +1,21 @@ +protocol PriceHistoryCalculatorFactoryProtocol { + func createPriceCalculator(for priceId: String?) -> TokenPriceCalculatorProtocol? + func replace(history: PriceHistory, priceId: AssetModel.PriceId) +} + +final class PriceHistoryCalculatorFactory: PriceHistoryCalculatorFactoryProtocol { + private var priceHistory: [AssetModel.PriceId: PriceHistory?] = [:] + + func replace(history: PriceHistory, priceId: AssetModel.PriceId) { + priceHistory[priceId] = history + } + + func createPriceCalculator(for priceId: String?) -> TokenPriceCalculatorProtocol? { + guard let priceId = priceId, + let priceHistory = priceHistory[priceId], + let history = priceHistory else { + return nil + } + return TokenPriceCalculator(history: history) + } +} diff --git a/novawallet/Common/Migration/SubstrateStorageVersion.swift b/novawallet/Common/Migration/SubstrateStorageVersion.swift index 0e5c3f3de6..a136c16f3b 100644 --- a/novawallet/Common/Migration/SubstrateStorageVersion.swift +++ b/novawallet/Common/Migration/SubstrateStorageVersion.swift @@ -18,6 +18,8 @@ enum SubstrateStorageVersion: String, CaseIterable { case version17 = "SubstrateDataModel17" case version18 = "SubstrateDataModel18" case version19 = "SubstrateDataModel19" + case version20 = "SubstrateDataModel20" + case version21 = "SubstrateDataModel21" static var current: SubstrateStorageVersion { allCases.last! @@ -62,6 +64,10 @@ enum SubstrateStorageVersion: String, CaseIterable { case .version18: return .version19 case .version19: + return .version20 + case .version20: + return .version21 + case .version21: return nil } } diff --git a/novawallet/Common/Model/AmountPair.swift b/novawallet/Common/Model/AmountPair.swift new file mode 100644 index 0000000000..09cb183fbc --- /dev/null +++ b/novawallet/Common/Model/AmountPair.swift @@ -0,0 +1,6 @@ +import Foundation + +struct AmountPair { + let amount: TAmount + let value: TValue +} diff --git a/novawallet/Common/Model/AssetBalanceExistence.swift b/novawallet/Common/Model/AssetBalanceExistence.swift index 215896ad09..dba715fd83 100644 --- a/novawallet/Common/Model/AssetBalanceExistence.swift +++ b/novawallet/Common/Model/AssetBalanceExistence.swift @@ -1,7 +1,7 @@ import Foundation import BigInt -struct AssetBalanceExistence { +struct AssetBalanceExistence: Equatable { let minBalance: BigUInt let isSelfSufficient: Bool } diff --git a/novawallet/Common/Model/AssetStorageInfo.swift b/novawallet/Common/Model/AssetStorageInfo.swift index 5ef08651bc..f50001a62f 100644 --- a/novawallet/Common/Model/AssetStorageInfo.swift +++ b/novawallet/Common/Model/AssetStorageInfo.swift @@ -19,9 +19,15 @@ struct NativeTokenStorageInfo { let transferCallPath: CallCodingPath } +struct AssetsPalletStorageInfo { + let assetId: JSON + let assetIdString: String + let palletName: String? +} + enum AssetStorageInfo { case native(info: NativeTokenStorageInfo) - case statemine(extras: StatemineAssetExtras) + case statemine(info: AssetsPalletStorageInfo) case orml(info: OrmlTokenStorageInfo) case erc20(contractAccount: AccountId) case evmNative @@ -49,7 +55,19 @@ extension AssetStorageInfo { throw AssetStorageInfoError.unexpectedTypeExtras } - return .statemine(extras: extras) + let assetId = try StatemineAssetSerializer.decode( + assetId: extras.assetId, + palletName: extras.palletName, + codingFactory: codingFactory + ) + + let info = AssetsPalletStorageInfo( + assetId: assetId, + assetIdString: extras.assetId, + palletName: extras.palletName + ) + + return .statemine(info: info) case .evmAsset: guard let contractAddress = asset.typeExtras?.stringValue else { throw AssetStorageInfoError.unexpectedTypeExtras diff --git a/novawallet/Common/Model/BigRational.swift b/novawallet/Common/Model/BigRational.swift new file mode 100644 index 0000000000..6b520bfd89 --- /dev/null +++ b/novawallet/Common/Model/BigRational.swift @@ -0,0 +1,50 @@ +import Foundation +import BigInt + +struct BigRational: Hashable { + let numerator: BigUInt + let denominator: BigUInt + + func mul(value: BigUInt) -> BigUInt { + value * numerator / denominator + } +} + +extension BigRational { + static func percent(of numerator: BigUInt) -> BigRational { + .init(numerator: numerator, denominator: 100) + } +} + +extension BigRational { + static func fraction(from number: Decimal) -> BigRational? { + let decimalNumber = NSDecimalNumber(decimal: number) + guard decimalNumber.doubleValue.remainder(dividingBy: 1) != 0 else { + return number.toSubstrateAmount(precision: 0).map { + BigRational(numerator: $0, denominator: 1) + } + } + let scale = -number.exponent + if let numerator = number.toSubstrateAmount(precision: Int16(scale)), + let denominator = Decimal(1).toSubstrateAmount(precision: Int16(scale)) { + return .init(numerator: numerator, denominator: denominator) + } + + return nil + } +} + +extension BigRational { + var decimalValue: Decimal? { + guard denominator != 0 else { + return nil + } + let numerator = numerator.decimal(precision: 0) + let denominator = denominator.decimal(precision: 0) + return numerator / denominator + } + + var decimalOrZeroValue: Decimal { + decimalValue ?? 0 + } +} diff --git a/novawallet/Common/Model/ChainRegistry/LocalChain/ChainModel.swift b/novawallet/Common/Model/ChainRegistry/LocalChain/ChainModel.swift index 7a2696c05e..560c1eeb15 100644 --- a/novawallet/Common/Model/ChainRegistry/LocalChain/ChainModel.swift +++ b/novawallet/Common/Model/ChainRegistry/LocalChain/ChainModel.swift @@ -110,6 +110,22 @@ struct ChainModel: Equatable, Codable, Hashable { assets.first { $0.assetId == assetId } } + func assetOrNil(for assetId: AssetModel.Id?) -> AssetModel? { + guard let assetId = assetId else { + return nil + } + + return assets.first { $0.assetId == assetId } + } + + func assetOrNative(for assetId: AssetModel.Id?) -> AssetModel? { + guard let assetId = assetId else { + return utilityAsset() + } + + return assets.first { $0.assetId == assetId } + } + func hasEnabledAsset() -> Bool { assets.contains { $0.enabled } } @@ -138,6 +154,14 @@ struct ChainModel: Equatable, Codable, Hashable { options?.contains(where: { $0 == .governance }) ?? false } + var hasSwapHub: Bool { + options?.contains(where: { $0 == .swapHub }) ?? false + } + + var hasSwaps: Bool { + hasSwapHub + } + var noSubstrateRuntime: Bool { options?.contains(where: { $0 == .noSubstrateRuntime }) ?? false } @@ -184,6 +208,14 @@ struct ChainModel: Equatable, Codable, Hashable { return ChainAssetId(chainId: chainId, assetId: utilityAsset.assetId) } + func utilityChainAsset() -> ChainAsset? { + guard let utilityAsset = utilityAssets().first else { + return nil + } + + return ChainAsset(chain: self, asset: utilityAsset) + } + var typesUsage: TypesUsage { if let types = types { return types.overridesCommon ? .onlyOwn : .both @@ -203,6 +235,18 @@ struct ChainModel: Equatable, Codable, Hashable { var defaultBlockTimeMillis: BlockTime? { additional?.defaultBlockTime?.unsignedIntValue } + + var isUtilityTokenOnRelaychain: Bool { + additional?.relaychainAsNative?.boolValue ?? false + } + + var stakingMaxElectingVoters: UInt32? { + guard let value = additional?.stakingMaxElectingVoters?.unsignedIntValue else { + return nil + } + + return UInt32(value) + } } extension ChainModel: Identifiable { @@ -216,6 +260,7 @@ enum ChainOptions: String, Codable { case governance case governanceV1 = "governance-v1" case noSubstrateRuntime + case swapHub = "swap-hub" } extension ChainModel { diff --git a/novawallet/Common/Model/KnownChainIds.swift b/novawallet/Common/Model/KnownChainIds.swift index b308e7f7c8..e530b57b21 100644 --- a/novawallet/Common/Model/KnownChainIds.swift +++ b/novawallet/Common/Model/KnownChainIds.swift @@ -25,6 +25,7 @@ enum KnowChainId { static let ethereum = "eip155:1" static let rococo = "a84b46a3e602245284bb9a72c4abd58ee979aa7a5d7f8c4dfdddfaaf0665a4ae" static let westend = "e143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e" + static let westmint = "67f9723393ef76214df0118c34bbbd3dbebc8ed46a10973a8c969d48fe7598c9" static var kiltOnEnviroment: String { #if F_DEV diff --git a/novawallet/Common/Model/StateminAssetExtras.swift b/novawallet/Common/Model/StateminAssetExtras.swift index 3699b68757..cda95d5b7b 100644 --- a/novawallet/Common/Model/StateminAssetExtras.swift +++ b/novawallet/Common/Model/StateminAssetExtras.swift @@ -1,6 +1,84 @@ import Foundation +import SubstrateSdk +import BigInt struct StatemineAssetExtras: Codable { let assetId: String let palletName: String? + + init(info: AssetsPalletStorageInfo) { + assetId = info.assetIdString + palletName = info.palletName + } +} + +enum StatemineAssetSerializerError: Error { + case assetIdTypeNotFound(palletName: String?) +} + +enum StatemineAssetSerializer { + private static func extractAssetIdType( + from codingFactory: RuntimeCoderFactoryProtocol, + palletName: String? + ) -> String? { + let callPath = PalletAssets.assetsTransfer(for: palletName) + + guard let call = codingFactory.getCall(for: callPath), !call.arguments.isEmpty else { + return nil + } + + return call.arguments[0].type + } + + static func decode( + assetId: String, + palletName: String?, + codingFactory: RuntimeCoderFactoryProtocol + ) throws -> JSON { + // assetId is either integer or complicated data structure + guard assetId.isHex() else { + return JSON.stringValue(assetId) + } + + guard let assetIdType = extractAssetIdType(from: codingFactory, palletName: palletName) else { + throw StatemineAssetSerializerError.assetIdTypeNotFound(palletName: palletName) + } + + let data = try Data(hexString: assetId) + + let decoder = try codingFactory.createDecoder(from: data) + + return try decoder.read(type: assetIdType) + } + + static func encode( + assetId: JSON, + palletName: String?, + codingFactory: RuntimeCoderFactoryProtocol + ) throws -> String { + // assetId is either integer or complicated data structure + if case let .stringValue(assetIdString) = assetId, BigUInt(assetIdString) != nil { + return assetIdString + } + + guard let assetIdType = extractAssetIdType(from: codingFactory, palletName: palletName) else { + throw StatemineAssetSerializerError.assetIdTypeNotFound(palletName: palletName) + } + + let encoder = codingFactory.createEncoder() + + try encoder.append(json: assetId, type: assetIdType) + + let data = try encoder.encode() + + return data.toHex(includePrefix: true) + } + + static func subscriptionKeyEncoder(for assetId: String) -> ((String) throws -> Data)? { + if assetId.isHex() { + return { try Data(hexString: $0) } + } else { + return nil + } + } } diff --git a/novawallet/Common/Model/TokenOperation.swift b/novawallet/Common/Model/TokenOperation.swift index 2c73cd9fce..ea3fc5abea 100644 --- a/novawallet/Common/Model/TokenOperation.swift +++ b/novawallet/Common/Model/TokenOperation.swift @@ -56,11 +56,27 @@ typealias TransferAvailableCheckResult = Bool enum ReceiveAvailableCheckResult { case common(OperationCheckCommonResult) + + var available: Bool { + switch self { + case let .common(operationCheckCommonResult): + return operationCheckCommonResult == .available + } + } } enum BuyAvailableCheckResult { case common(OperationCheckCommonResult) case noBuyOptions + + var available: Bool { + switch self { + case let .common(operationCheckCommonResult): + return operationCheckCommonResult == .available + case .noBuyOptions: + return false + } + } } enum OperationCheckCommonResult { diff --git a/novawallet/Common/Model/TransactionHistoryItem.swift b/novawallet/Common/Model/TransactionHistoryItem.swift index 28a3ce51fd..729e6ce580 100644 --- a/novawallet/Common/Model/TransactionHistoryItem.swift +++ b/novawallet/Common/Model/TransactionHistoryItem.swift @@ -21,10 +21,12 @@ struct TransactionHistoryItem: Codable { case txHash case timestamp case fee + case feeAssetId case blockNumber case txIndex case callPath case call + case swap } enum Status: Int16, Codable { @@ -44,10 +46,12 @@ struct TransactionHistoryItem: Codable { let txHash: String let timestamp: Int64 let fee: String? + let feeAssetId: UInt32? let blockNumber: UInt64? let txIndex: UInt16? let callPath: CallCodingPath let call: Data? + let swap: SwapHistoryData? } extension TransactionHistoryItem: Identifiable { @@ -83,3 +87,17 @@ extension TransactionHistoryItemSource { } } } + +struct SwapHistoryData: Codable { + enum CodingKeys: String, CodingKey { + case amountIn + case assetIdIn + case amountOut + case assetIdOut + } + + let amountIn: String + let assetIdIn: AssetModel.Id? + let amountOut: String + let assetIdOut: AssetModel.Id? +} diff --git a/novawallet/Common/Model/TransactionType.swift b/novawallet/Common/Model/TransactionType.swift index 936318b1ac..e48357e6d9 100644 --- a/novawallet/Common/Model/TransactionType.swift +++ b/novawallet/Common/Model/TransactionType.swift @@ -8,4 +8,5 @@ enum TransactionType: String, CaseIterable, Equatable { case extrinsic = "EXTRINSIC" case poolReward = "POOL REWARD" case poolSlash = "POOL SLASH" + case swap = "SWAP" } diff --git a/novawallet/Common/Model/WalletHistoryFilter.swift b/novawallet/Common/Model/WalletHistoryFilter.swift index 5b766619cc..3a8804b281 100644 --- a/novawallet/Common/Model/WalletHistoryFilter.swift +++ b/novawallet/Common/Model/WalletHistoryFilter.swift @@ -6,7 +6,8 @@ struct WalletHistoryFilter: OptionSet { static let transfers = WalletHistoryFilter(rawValue: 1 << 0) static let rewardsAndSlashes = WalletHistoryFilter(rawValue: 1 << 1) static let extrinsics = WalletHistoryFilter(rawValue: 1 << 2) - static let all: WalletHistoryFilter = [.transfers, .rewardsAndSlashes, .extrinsics] + static let swaps = WalletHistoryFilter(rawValue: 1 << 3) + static let all: WalletHistoryFilter = [.transfers, .rewardsAndSlashes, .extrinsics, .swaps] let rawValue: UInt8 diff --git a/novawallet/Common/Model/Xcm/XcmTransferFactory.swift b/novawallet/Common/Model/Xcm/XcmTransferFactory.swift index ca394903b3..229bef65e4 100644 --- a/novawallet/Common/Model/Xcm/XcmTransferFactory.swift +++ b/novawallet/Common/Model/Xcm/XcmTransferFactory.swift @@ -91,7 +91,7 @@ extension XcmTransferFactoryProtocol { } final class XcmTransferFactory { - private func extractRelativeJunctions(from path: JSON) throws -> Xcm.Junctions { + private func extractRelativeJunctions(from path: JSON) throws -> Xcm.JunctionsV2 { var junctions: [Xcm.Junction] = [] if let palletInstance = path.palletInstance?.unsignedIntValue { @@ -112,7 +112,7 @@ final class XcmTransferFactory { return Xcm.Junctions(items: junctions) } - private func extractAbsoluteJunctions(from path: JSON) throws -> Xcm.Junctions { + private func extractAbsoluteJunctions(from path: JSON) throws -> Xcm.JunctionsV2 { let commonJunctions = try extractRelativeJunctions(from: path) if let parachainId = path.parachainId?.unsignedIntValue { @@ -150,7 +150,7 @@ final class XcmTransferFactory { reserve: ChainModel ) throws -> Xcm.Multilocation { let parents = extractParents(from: path, type: type, origin: origin, reserve: reserve) - let junctions: Xcm.Junctions + let junctions: Xcm.JunctionsV2 switch type { case .absolute, .concrete: @@ -188,7 +188,7 @@ final class XcmTransferFactory { parents = 0 } - let junctions: Xcm.Junctions + let junctions: Xcm.JunctionsV2 if let parachainId = destination.parachainId { let networkJunction = Xcm.Junction.parachain(parachainId) @@ -212,13 +212,13 @@ final class XcmTransferFactory { parents = 0 } - let junctions: Xcm.Junctions + let junctions: Xcm.JunctionsV2 if let parachainId = reserve.parachainId { let networkJunction = Xcm.Junction.parachain(parachainId) - junctions = Xcm.Junctions(items: [networkJunction]) + junctions = Xcm.JunctionsV2(items: [networkJunction]) } else { - junctions = Xcm.Junctions(items: []) + junctions = Xcm.JunctionsV2(items: []) } return Xcm.Multilocation(parents: parents, interior: junctions) diff --git a/novawallet/Common/Model/Xcm/XcmTransfers.swift b/novawallet/Common/Model/Xcm/XcmTransfers.swift index fe8329453f..d4aede988a 100644 --- a/novawallet/Common/Model/Xcm/XcmTransfers.swift +++ b/novawallet/Common/Model/Xcm/XcmTransfers.swift @@ -83,6 +83,19 @@ struct XcmTransfers: Decodable { return xcmTransfers } + func transferChainAssets(to chainAssetId: ChainAssetId) -> [ChainAssetId] { + chains.flatMap { chain in + chain.assets.filter { asset in + asset.xcmTransfers.contains(where: { transfer in + transfer.destination.chainId == chainAssetId.chainId && + transfer.destination.assetId == chainAssetId.assetId + }) + }.map { + ChainAssetId(chainId: chain.chainId, assetId: $0.assetId) + } + } + } + func transfer( from chainAssetId: ChainAssetId, destinationChainId: ChainModel.Id diff --git a/novawallet/Common/Network/Etherscan/ERC20/EtherscanERC20HistoryResponse.swift b/novawallet/Common/Network/Etherscan/ERC20/EtherscanERC20HistoryResponse.swift index bf84a84eae..67b6a199dc 100644 --- a/novawallet/Common/Network/Etherscan/ERC20/EtherscanERC20HistoryResponse.swift +++ b/novawallet/Common/Network/Etherscan/ERC20/EtherscanERC20HistoryResponse.swift @@ -78,10 +78,12 @@ extension EtherscanERC20HistoryResponse.Element: WalletRemoteHistoryItemProtocol txHash: txHash, timestamp: timeStamp, fee: String(feeInPlank), + feeAssetId: nil, blockNumber: itemBlockNumber, txIndex: itemExtrinsicIndex, callPath: .erc20Tranfer, - call: nil + call: nil, + swap: nil ) } } diff --git a/novawallet/Common/Network/Etherscan/Native/EtherscanNativeOperationFactory.swift b/novawallet/Common/Network/Etherscan/Native/EtherscanNativeOperationFactory.swift index 26bca6ece1..6aa9098f9b 100644 --- a/novawallet/Common/Network/Etherscan/Native/EtherscanNativeOperationFactory.swift +++ b/novawallet/Common/Network/Etherscan/Native/EtherscanNativeOperationFactory.swift @@ -63,6 +63,8 @@ final class EtherscanNativeOperationFactory: EtherscanBaseOperationFactory { return filter.contains(.extrinsics) case .rewards, .poolRewards: return filter.contains(.rewardsAndSlashes) + case .swaps: + return filter.contains(.swaps) } } diff --git a/novawallet/Common/Network/Etherscan/Native/EtherscanTxHistoryResponse.swift b/novawallet/Common/Network/Etherscan/Native/EtherscanTxHistoryResponse.swift index 82309cae71..89002d2a31 100644 --- a/novawallet/Common/Network/Etherscan/Native/EtherscanTxHistoryResponse.swift +++ b/novawallet/Common/Network/Etherscan/Native/EtherscanTxHistoryResponse.swift @@ -98,10 +98,12 @@ extension EtherscanTxHistoryResponse.Element: WalletRemoteHistoryItemProtocol { txHash: txHash, timestamp: timeStamp, fee: String(feeInPlank), + feeAssetId: nil, blockNumber: itemBlockNumber, txIndex: itemExtrinsicIndex, callPath: isTransfer ? .evmNativeTransfer : .evmNativeTransaction, - call: callData + call: callData, + swap: nil ) } } diff --git a/novawallet/Common/Network/Subquery/Filter/SubqueryFilter.swift b/novawallet/Common/Network/Subquery/Filter/SubqueryFilter.swift index 417936a64c..a8488e58e2 100644 --- a/novawallet/Common/Network/Subquery/Filter/SubqueryFilter.swift +++ b/novawallet/Common/Network/Subquery/Filter/SubqueryFilter.swift @@ -35,6 +35,15 @@ struct SubqueryLessThanOrEqualToFilter: SubqueryFilter { } } +struct SubqueryValueFilter: SubqueryFilter { + let fieldName: String + let value: T + + func rawSubqueryFilter() -> String { + "\(fieldName): \(value.rawSubqueryFilter())" + } +} + struct SubqueryIsNotNullFilter: SubqueryFilter { let fieldName: String @@ -43,6 +52,48 @@ struct SubqueryIsNotNullFilter: SubqueryFilter { } } +struct SubqueryNotFilter: SubqueryFilter { + let fieldName: String + let inner: SubqueryFilter + + func rawSubqueryFilter() -> String { + "not: { \(fieldName): { \(inner.rawSubqueryFilter()) }}" + } +} + +struct SubqueryNotWithCompoundFilter: SubqueryFilter { + let inner: SubqueryCompoundFilter + + func rawSubqueryFilter() -> String { + "not: { \(inner.rawSubqueryFilter()) }" + } +} + +struct SubqueryContainsFilter: SubqueryFilter { + let fieldName: String + let inner: SubqueryFilter + + func rawSubqueryFilter() -> String { + "\(fieldName): { contains: { \(inner.rawSubqueryFilter()) } }" + } +} + +struct SubqueryContainsKeyFilter: SubqueryFilter { + let fieldName: String + + func rawSubqueryFilter() -> String { + "containsKey: \(fieldName.rawSubqueryFilter())" + } +} + +struct SubqueryIsNullFilter: SubqueryFilter { + let fieldName: String + + func rawSubqueryFilter() -> String { + "\(fieldName): { isNull: true }" + } +} + extension String: SubqueryFilterValue { func rawSubqueryFilter() -> String { "\"\(self)\"" diff --git a/novawallet/Common/Network/Subquery/Models/SubqueryHistory.swift b/novawallet/Common/Network/Subquery/Models/SubqueryHistory.swift index f8dfb3fa4d..a0ea5d26f2 100644 --- a/novawallet/Common/Network/Subquery/Models/SubqueryHistory.swift +++ b/novawallet/Common/Network/Subquery/Models/SubqueryHistory.swift @@ -48,7 +48,26 @@ struct SubqueryPoolRewardOrSlash: Codable { let poolId: Int } +struct SubquerySwap: Codable { + let assetIdIn: String + let amountIn: String + let assetIdOut: String + let amountOut: String + let sender: String + let receiver: String + let assetIdFee: String + let fee: String + let eventIdx: Int + let success: Bool + + var isFeeNative: Bool { + assetIdFee == SubqueryHistoryElement.nativeFeeAssetId + } +} + struct SubqueryHistoryElement: Decodable { + static let nativeFeeAssetId = "native" + enum CodingKeys: String, CodingKey { case identifier = "id" case blockNumber @@ -61,6 +80,7 @@ struct SubqueryHistoryElement: Decodable { case transfer case assetTransfer case poolReward + case swap } let identifier: String @@ -74,6 +94,7 @@ struct SubqueryHistoryElement: Decodable { let transfer: SubqueryTransfer? let assetTransfer: SubqueryTransfer? let poolReward: SubqueryPoolRewardOrSlash? + let swap: SubquerySwap? } struct SubqueryHistoryData: Decodable { diff --git a/novawallet/Common/Network/Subquery/SubqueryHistory+Wallet.swift b/novawallet/Common/Network/Subquery/SubqueryHistory+Wallet.swift index 50577fe735..159ef165e1 100644 --- a/novawallet/Common/Network/Subquery/SubqueryHistory+Wallet.swift +++ b/novawallet/Common/Network/Subquery/SubqueryHistory+Wallet.swift @@ -31,6 +31,8 @@ extension SubqueryHistoryElement: WalletRemoteHistoryItemProtocol { return .rewards } else if poolReward != nil { return .poolRewards + } else if swap != nil { + return .swaps } else { return .extrinsics } @@ -45,6 +47,12 @@ extension SubqueryHistoryElement: WalletRemoteHistoryItemProtocol { chainAssetId: chainAsset.chainAssetId, chainFormat: chainAsset.chain.chainFormat ) + } else if let swap = swap { + return createTransactionFromSwap( + swap, + chainAsset: chainAsset, + chainFormat: chainAsset.chain.chainFormat + ) } else if let reward = reward { return createTransactionFromReward( reward, @@ -93,13 +101,59 @@ extension SubqueryHistoryElement: WalletRemoteHistoryItemProtocol { txHash: extrinsicHash ?? identifier, timestamp: itemTimestamp, fee: transfer.fee, + feeAssetId: nil, blockNumber: blockNumber, txIndex: nil, callPath: CallCodingPath.transfer, - call: nil + call: nil, + swap: nil + ) + } + + private func createTransactionFromSwap( + _ swap: SubquerySwap, + chainAsset: ChainAsset, + chainFormat: ChainFormat + ) -> TransactionHistoryItem { + let source = TransactionHistoryItemSource.substrate + let remoteIdentifier = TransactionHistoryItem.createIdentifier(from: identifier, source: source) + + let feeAsset = mapFromSwapHistoryAssetId(swap.assetIdFee, chain: chainAsset.chain) + + return .init( + identifier: remoteIdentifier, + source: source, + chainId: chainAsset.chain.chainId, + assetId: chainAsset.asset.assetId, + sender: swap.sender.normalize(for: chainFormat) ?? swap.sender, + receiver: swap.receiver.normalize(for: chainFormat) ?? swap.receiver, + amountInPlank: nil, + status: swap.success ? .success : .failed, + txHash: extrinsicHash ?? identifier, + timestamp: itemTimestamp, + fee: swap.fee, + feeAssetId: feeAsset?.assetId, + blockNumber: blockNumber, + txIndex: nil, + callPath: AssetConversionPallet.swapExactTokenForTokensPath, + call: nil, + swap: .init( + amountIn: swap.amountIn, + assetIdIn: mapFromSwapHistoryAssetId(swap.assetIdIn, chain: chainAsset.chain)?.assetId, + amountOut: swap.amountOut, + assetIdOut: mapFromSwapHistoryAssetId(swap.assetIdOut, chain: chainAsset.chain)?.assetId + ) ) } + private func mapFromSwapHistoryAssetId(_ assetId: String, chain: ChainModel) -> AssetModel? { + if assetId == SubqueryHistoryElement.nativeFeeAssetId { + return chain.utilityAsset() + } else { + return chain.asset(byHistoryAssetId: assetId) + } + } + private func createTransactionFromReward( _ reward: SubqueryRewardOrSlash, chainAssetId: ChainAssetId, @@ -126,10 +180,12 @@ extension SubqueryHistoryElement: WalletRemoteHistoryItemProtocol { txHash: extrinsicHash ?? identifier, timestamp: itemTimestamp, fee: nil, + feeAssetId: nil, blockNumber: blockNumber, txIndex: nil, callPath: reward.isReward ? .reward : .slash, - call: try? JSONEncoder().encode(context) + call: try? JSONEncoder().encode(context), + swap: nil ) } @@ -158,10 +214,12 @@ extension SubqueryHistoryElement: WalletRemoteHistoryItemProtocol { txHash: extrinsicHash ?? identifier, timestamp: itemTimestamp, fee: nil, + feeAssetId: nil, blockNumber: blockNumber, txIndex: nil, callPath: reward.isReward ? .poolReward : .poolSlash, - call: try? JSONEncoder().encode(context) + call: try? JSONEncoder().encode(context), + swap: nil ) } @@ -185,10 +243,12 @@ extension SubqueryHistoryElement: WalletRemoteHistoryItemProtocol { txHash: extrinsicHash ?? identifier, timestamp: itemTimestamp, fee: extrinsic.fee, + feeAssetId: nil, blockNumber: blockNumber, txIndex: nil, callPath: CallCodingPath(moduleName: extrinsic.module, callName: extrinsic.call), - call: extrinsic.call.data(using: .utf8) + call: extrinsic.call.data(using: .utf8), + swap: nil ) } diff --git a/novawallet/Common/Network/Subquery/SubqueryHistoryOperationFactory.swift b/novawallet/Common/Network/Subquery/SubqueryHistoryOperationFactory.swift index 6382121d2a..08a0689ee4 100644 --- a/novawallet/Common/Network/Subquery/SubqueryHistoryOperationFactory.swift +++ b/novawallet/Common/Network/Subquery/SubqueryHistoryOperationFactory.swift @@ -15,40 +15,66 @@ final class SubqueryHistoryOperationFactory { let filter: WalletHistoryFilter let assetId: String? let hasPoolStaking: Bool - - init(url: URL, filter: WalletHistoryFilter, assetId: String?, hasPoolStaking: Bool) { + let hasSwaps: Bool + + init( + url: URL, + filter: WalletHistoryFilter, + assetId: String?, + hasPoolStaking: Bool, + hasSwaps: Bool + ) { self.url = url self.filter = filter self.assetId = assetId self.hasPoolStaking = hasPoolStaking + self.hasSwaps = hasSwaps } private func prepareExtrinsicInclusionFilter() -> String { - """ - { - and: [ - { - extrinsic: {isNull: false} - }, - { - not: { - and: [ - { extrinsic: { contains: {module: "balances"} } }, - { - or: [ - { extrinsic: {contains: {call: "transfer"} } }, - { extrinsic: {contains: {call: "transferKeepAlive"} } }, - { extrinsic: {contains: {call: "forceTransfer"} } }, - { extrinsic: {contains: {call: "transferAll"} } }, - { extrinsic: {contains: {call: "transferAllowDeath"} } } - ] - } - ] - } - } - ] + let transferCallNames = ["transfer", "transferKeepAlive", "forceTransfer", "transferAll", "transferAllowDeath"] + + let transferFilters = transferCallNames.map { transferCallName in + SubqueryContainsFilter( + fieldName: "extrinsic", + inner: SubqueryValueFilter(fieldName: "call", value: transferCallName) + ) } - """ + + let swapCallNames = ["swapExactTokensForTokens", "swapTokensForExactTokens"] + + let swapFilters = swapCallNames.map { swapCallName in + SubqueryContainsFilter( + fieldName: "extrinsic", + inner: SubqueryValueFilter(fieldName: "call", value: swapCallName) + ) + } + + return SubqueryInnerFilter( + inner: SubqueryCompoundFilter.and( + [ + SubqueryIsNotNullFilter(fieldName: "extrinsic"), + SubqueryNotWithCompoundFilter( + inner: .or([ + SubqueryCompoundFilter.and([ + SubqueryContainsFilter( + fieldName: "extrinsic", + inner: SubqueryValueFilter(fieldName: "module", value: "balances") + ), + SubqueryCompoundFilter.or(transferFilters) + ]), + SubqueryCompoundFilter.and([ + SubqueryContainsFilter( + fieldName: "extrinsic", + inner: SubqueryValueFilter(fieldName: "module", value: "assetConversion") + ), + SubqueryCompoundFilter.or(swapFilters) + ]) + ]) + ) + ] + ) + ).rawSubqueryFilter() } private func prepareAssetIdFilter(_ assetId: String) -> String { @@ -59,6 +85,23 @@ final class SubqueryHistoryOperationFactory { """ } + private func prepareSwapAssetIdFilter(_ assetId: String?) -> String { + let assetIdOrNative = assetId ?? SubqueryHistoryElement.nativeFeeAssetId + + let filters = [ + SubqueryContainsFilter( + fieldName: "swap", + inner: SubqueryValueFilter(fieldName: "assetIdIn", value: assetIdOrNative) + ), + SubqueryContainsFilter( + fieldName: "swap", + inner: SubqueryValueFilter(fieldName: "assetIdOut", value: assetIdOrNative) + ) + ] + + return SubqueryInnerFilter(inner: SubqueryCompoundFilter.or(filters)).rawSubqueryFilter() + } + private func prepareFilter() -> String { var filterStrings: [String] = [] @@ -87,6 +130,10 @@ final class SubqueryHistoryOperationFactory { } } + if filter.contains(.swaps), hasSwaps { + filterStrings.append(prepareSwapAssetIdFilter(assetId)) + } + return filterStrings.joined(separator: ",") } @@ -99,6 +146,7 @@ final class SubqueryHistoryOperationFactory { let transferField = assetId != nil ? "assetTransfer" : "transfer" let filterString = prepareFilter() let poolRewardField = hasPoolStaking ? "poolReward" : "" + let swapField = hasSwaps ? "swap" : "" return """ { historyElements( @@ -127,6 +175,7 @@ final class SubqueryHistoryOperationFactory { extrinsic \(transferField) \(poolRewardField) + \(swapField) } } } diff --git a/novawallet/Common/Operation/StorageKeyEncodingOperation.swift b/novawallet/Common/Operation/StorageKeyEncodingOperation.swift index eab497313b..75eab7c33f 100644 --- a/novawallet/Common/Operation/StorageKeyEncodingOperation.swift +++ b/novawallet/Common/Operation/StorageKeyEncodingOperation.swift @@ -60,14 +60,21 @@ class UnkeyedEncodingOperation: BaseOperation { class MapKeyEncodingOperation: BaseOperation<[Data]> { var keyParams: [T]? var codingFactory: RuntimeCoderFactoryProtocol? + var paramEncoder: ((T) throws -> Data)? let path: StorageCodingPath let storageKeyFactory: StorageKeyFactoryProtocol - init(path: StorageCodingPath, storageKeyFactory: StorageKeyFactoryProtocol, keyParams: [T]? = nil) { + init( + path: StorageCodingPath, + storageKeyFactory: StorageKeyFactoryProtocol, + keyParams: [T]? = nil, + paramEncoder: ((T) throws -> Data)? = nil + ) { self.path = path self.keyParams = keyParams self.storageKeyFactory = storageKeyFactory + self.paramEncoder = paramEncoder super.init() } @@ -109,10 +116,17 @@ class MapKeyEncodingOperation: BaseOperation<[Data]> { } let keys: [Data] = try keyParams.map { keyParam in - let encoder = factory.createEncoder() - try encoder.append(keyParam, ofType: keyType) + let encodedParam: Data - let encodedParam = try encoder.encode() + if let paramEncoder = paramEncoder { + encodedParam = try paramEncoder(keyParam) + } else { + encodedParam = try encodeParam( + keyParam, + factory: factory, + type: keyType + ) + } return try storageKeyFactory.createStorageKey( moduleName: path.moduleName, @@ -141,6 +155,16 @@ class MapKeyEncodingOperation: BaseOperation<[Data]> { performEncoding() } + + private func encodeParam( + _ param: T, + factory: RuntimeCoderFactoryProtocol, + type: String + ) throws -> Data { + let encoder = factory.createEncoder() + try encoder.append(param, ofType: type) + return try encoder.encode() + } } class DoubleMapKeyEncodingOperation: BaseOperation<[Data]> { diff --git a/novawallet/Common/Protocols/JSONListConvertible.swift b/novawallet/Common/Protocols/JSONListConvertible.swift index 03148f3eb8..b6e908bcf1 100644 --- a/novawallet/Common/Protocols/JSONListConvertible.swift +++ b/novawallet/Common/Protocols/JSONListConvertible.swift @@ -3,6 +3,7 @@ import SubstrateSdk enum JSONListConvertibleError: Error { case unexpectedNumberOfItems(expected: Int, actual: Int) + case unexpectedValue(JSON) } protocol JSONListConvertible { diff --git a/novawallet/Common/Protocols/PurchaseFlowManaging.swift b/novawallet/Common/Protocols/PurchaseFlowManaging.swift index 35348b3a48..b317ce4ce0 100644 --- a/novawallet/Common/Protocols/PurchaseFlowManaging.swift +++ b/novawallet/Common/Protocols/PurchaseFlowManaging.swift @@ -1,6 +1,6 @@ import SoraFoundation -protocol PurchaseFlowManaging { +protocol PurchaseFlowManaging: AnyObject { func startPuchaseFlow( from view: ControllerBackedProtocol?, purchaseActions: [PurchaseAction], diff --git a/novawallet/Common/Protocols/PurchasePresentable.swift b/novawallet/Common/Protocols/PurchasePresentable.swift index 67cc3affbd..60aaa5c677 100644 --- a/novawallet/Common/Protocols/PurchasePresentable.swift +++ b/novawallet/Common/Protocols/PurchasePresentable.swift @@ -43,7 +43,7 @@ extension PurchasePresentable { guard let pickerView = ModalPickerFactory.createPickerForList( actions, delegate: delegate, - context: nil + context: actions as NSArray ) else { return } diff --git a/novawallet/Common/Protocols/ShortTextInfoPresentable.swift b/novawallet/Common/Protocols/ShortTextInfoPresentable.swift new file mode 100644 index 0000000000..5603e2caea --- /dev/null +++ b/novawallet/Common/Protocols/ShortTextInfoPresentable.swift @@ -0,0 +1,34 @@ +import SoraFoundation +import SoraUI + +protocol ShortTextInfoPresentable { + func showInfo( + from view: ControllerBackedProtocol?, + title: LocalizableResource, + details: LocalizableResource + ) +} + +extension ShortTextInfoPresentable { + func showInfo( + from view: ControllerBackedProtocol?, + title: LocalizableResource, + details: LocalizableResource + ) { + let viewModel = TitleDetailsSheetViewModel( + title: title, + message: details, + mainAction: nil, + secondaryAction: nil + ) + + let bottomSheet = TitleDetailsSheetViewFactory.createContentSizedView(from: viewModel) + + let factory = ModalSheetPresentationFactory(configuration: ModalSheetPresentationConfiguration.nova) + + bottomSheet.controller.modalTransitioningFactory = factory + bottomSheet.controller.modalPresentationStyle = .custom + + view?.controller.present(bottomSheet.controller, animated: true) + } +} diff --git a/novawallet/Common/Services/ExtrinsicService/Substrate/ExtrinsicService.swift b/novawallet/Common/Services/ExtrinsicService/Substrate/ExtrinsicService.swift index 0f06c109db..a53a3f2ffe 100644 --- a/novawallet/Common/Services/ExtrinsicService/Substrate/ExtrinsicService.swift +++ b/novawallet/Common/Services/ExtrinsicService/Substrate/ExtrinsicService.swift @@ -105,6 +105,7 @@ final class ExtrinsicService { cryptoType: MultiassetCryptoType, walletType: MetaAccountModelType, runtimeRegistry: RuntimeCodingServiceProtocol, + extensions: [ExtrinsicExtension], engine: JSONRPCEngine, operationManager: OperationManagerProtocol ) { @@ -114,7 +115,7 @@ final class ExtrinsicService { cryptoType: cryptoType, signaturePayloadFormat: walletType.signaturePayloadFormat, runtimeRegistry: runtimeRegistry, - customExtensions: DefaultExtrinsicExtension.extensions, + customExtensions: extensions, engine: engine, operationManager: operationManager ) diff --git a/novawallet/Common/Services/ExtrinsicService/Substrate/ExtrinsicServiceFactory.swift b/novawallet/Common/Services/ExtrinsicService/Substrate/ExtrinsicServiceFactory.swift index d6a17fc9d8..33ced3b4ea 100644 --- a/novawallet/Common/Services/ExtrinsicService/Substrate/ExtrinsicServiceFactory.swift +++ b/novawallet/Common/Services/ExtrinsicService/Substrate/ExtrinsicServiceFactory.swift @@ -5,15 +5,65 @@ import SubstrateSdk protocol ExtrinsicServiceFactoryProtocol { func createService( account: ChainAccountResponse, - chain: ChainModel + chain: ChainModel, + extensions: [ExtrinsicExtension] ) -> ExtrinsicServiceProtocol func createOperationFactory( account: ChainAccountResponse, - chain: ChainModel + chain: ChainModel, + extensions: [ExtrinsicExtension] ) -> ExtrinsicOperationFactoryProtocol } +extension ExtrinsicServiceFactoryProtocol { + func createService( + account: ChainAccountResponse, + chain: ChainModel + ) -> ExtrinsicServiceProtocol { + createService( + account: account, + chain: chain, + extensions: DefaultExtrinsicExtension.extensions() + ) + } + + func createService( + account: ChainAccountResponse, + chain: ChainModel, + feeAssetConversionId: AssetConversionPallet.AssetId + ) -> ExtrinsicServiceProtocol { + createService( + account: account, + chain: chain, + extensions: DefaultExtrinsicExtension.extensions(payingFeeIn: feeAssetConversionId) + ) + } + + func createOperationFactory( + account: ChainAccountResponse, + chain: ChainModel + ) -> ExtrinsicOperationFactoryProtocol { + createOperationFactory( + account: account, + chain: chain, + extensions: DefaultExtrinsicExtension.extensions() + ) + } + + func createOperationFactory( + account: ChainAccountResponse, + chain: ChainModel, + feeAssetConversionId: AssetConversionPallet.AssetId + ) -> ExtrinsicOperationFactoryProtocol { + createOperationFactory( + account: account, + chain: chain, + extensions: DefaultExtrinsicExtension.extensions(payingFeeIn: feeAssetConversionId) + ) + } +} + final class ExtrinsicServiceFactory { private let runtimeRegistry: RuntimeCodingServiceProtocol private let engine: JSONRPCEngine @@ -33,7 +83,8 @@ final class ExtrinsicServiceFactory { extension ExtrinsicServiceFactory: ExtrinsicServiceFactoryProtocol { func createService( account: ChainAccountResponse, - chain: ChainModel + chain: ChainModel, + extensions: [ExtrinsicExtension] ) -> ExtrinsicServiceProtocol { ExtrinsicService( accountId: account.accountId, @@ -41,6 +92,7 @@ extension ExtrinsicServiceFactory: ExtrinsicServiceFactoryProtocol { cryptoType: account.cryptoType, walletType: account.type, runtimeRegistry: runtimeRegistry, + extensions: extensions, engine: engine, operationManager: operationManager ) @@ -48,7 +100,8 @@ extension ExtrinsicServiceFactory: ExtrinsicServiceFactoryProtocol { func createOperationFactory( account: ChainAccountResponse, - chain: ChainModel + chain: ChainModel, + extensions: [ExtrinsicExtension] ) -> ExtrinsicOperationFactoryProtocol { ExtrinsicOperationFactory( accountId: account.accountId, @@ -56,7 +109,7 @@ extension ExtrinsicServiceFactory: ExtrinsicServiceFactoryProtocol { cryptoType: account.cryptoType, signaturePayloadFormat: account.type.signaturePayloadFormat, runtimeRegistry: runtimeRegistry, - customExtensions: DefaultExtrinsicExtension.extensions, + customExtensions: extensions, engine: engine, operationManager: operationManager ) diff --git a/novawallet/Common/Services/ExtrinsicService/Substrate/Xcm/XcmTransferService.swift b/novawallet/Common/Services/ExtrinsicService/Substrate/Xcm/XcmTransferService.swift index 6e6a55a228..aeba25c568 100644 --- a/novawallet/Common/Services/ExtrinsicService/Substrate/Xcm/XcmTransferService.swift +++ b/novawallet/Common/Services/ExtrinsicService/Substrate/Xcm/XcmTransferService.swift @@ -77,7 +77,7 @@ final class XcmTransferService { cryptoType: cryptoType, signaturePayloadFormat: signaturePayloadFormat, runtimeRegistry: runtimeProvider, - customExtensions: DefaultExtrinsicExtension.extensions, + customExtensions: DefaultExtrinsicExtension.extensions(), engine: connection, operationManager: OperationManager(operationQueue: operationQueue) ) diff --git a/novawallet/Common/Services/PersistExtrinsicService/PersistExtrinsicFactory.swift b/novawallet/Common/Services/PersistExtrinsicService/PersistExtrinsicFactory.swift index a0a69df69d..a845c1d80c 100644 --- a/novawallet/Common/Services/PersistExtrinsicService/PersistExtrinsicFactory.swift +++ b/novawallet/Common/Services/PersistExtrinsicService/PersistExtrinsicFactory.swift @@ -14,6 +14,12 @@ protocol PersistExtrinsicFactoryProtocol { chainAssetId: ChainAssetId, details: PersistExtrinsicDetails ) -> CompoundOperationWrapper + + func createSwapSaveOperation( + source: TransactionHistoryItemSource, + chainAssetId: ChainAssetId, + details: PersistSwapDetails + ) -> CompoundOperationWrapper } final class PersistExtrinsicFactory: PersistExtrinsicFactoryProtocol { @@ -46,10 +52,12 @@ final class PersistExtrinsicFactory: PersistExtrinsicFactoryProtocol { txHash: txHash, timestamp: timestamp, fee: feeString, + feeAssetId: nil, blockNumber: nil, txIndex: nil, callPath: details.callPath, - call: nil + call: nil, + swap: nil ) let operation = repository.saveOperation({ [transferItem] }, { [] }) @@ -80,10 +88,55 @@ final class PersistExtrinsicFactory: PersistExtrinsicFactoryProtocol { txHash: txHash, timestamp: timestamp, fee: feeString, + feeAssetId: nil, + blockNumber: nil, + txIndex: nil, + callPath: details.callPath, + call: nil, + swap: nil + ) + + let operation = repository.saveOperation({ [item] }, { [] }) + + return CompoundOperationWrapper(targetOperation: operation) + } + + func createSwapSaveOperation( + source: TransactionHistoryItemSource, + chainAssetId: ChainAssetId, + details: PersistSwapDetails + ) -> CompoundOperationWrapper { + let timestamp = Int64(Date().timeIntervalSince1970) + let feeString = details.fee.map { String($0) } + + let txHash = details.txHash.toHex(includePrefix: true) + let identifier = TransactionHistoryItem.createIdentifier(from: txHash, source: source) + + let swap = SwapHistoryData( + amountIn: String(details.amountIn), + assetIdIn: details.assetIdIn.assetId, + amountOut: String(details.amountOut), + assetIdOut: details.assetIdOut.assetId + ) + + let item = TransactionHistoryItem( + identifier: identifier, + source: source, + chainId: chainAssetId.chainId, + assetId: chainAssetId.assetId, + sender: details.sender, + receiver: details.receiver, + amountInPlank: nil, + status: .pending, + txHash: txHash, + timestamp: timestamp, + fee: feeString, + feeAssetId: details.feeAssetId, blockNumber: nil, txIndex: nil, callPath: details.callPath, - call: nil + call: nil, + swap: swap ) let operation = repository.saveOperation({ [item] }, { [] }) diff --git a/novawallet/Common/Services/PersistExtrinsicService/PersistTransferDetails.swift b/novawallet/Common/Services/PersistExtrinsicService/PersistTransferDetails.swift index f0177103f1..558fd1a0b5 100644 --- a/novawallet/Common/Services/PersistExtrinsicService/PersistTransferDetails.swift +++ b/novawallet/Common/Services/PersistExtrinsicService/PersistTransferDetails.swift @@ -16,3 +16,16 @@ struct PersistExtrinsicDetails { let callPath: CallCodingPath let fee: BigUInt? } + +struct PersistSwapDetails { + let txHash: Data + let sender: AccountAddress + let receiver: AccountAddress + let assetIdIn: ChainAssetId + let amountIn: BigUInt + let assetIdOut: ChainAssetId + let amountOut: BigUInt + let fee: BigUInt? + let feeAssetId: AssetModel.Id + let callPath: CallCodingPath +} diff --git a/novawallet/Common/Services/PersistExtrinsicService/PersistentExtrinsicService.swift b/novawallet/Common/Services/PersistExtrinsicService/PersistentExtrinsicService.swift index 6f65428210..2571387177 100644 --- a/novawallet/Common/Services/PersistExtrinsicService/PersistentExtrinsicService.swift +++ b/novawallet/Common/Services/PersistExtrinsicService/PersistentExtrinsicService.swift @@ -17,6 +17,14 @@ protocol PersistentExtrinsicServiceProtocol { runningIn queue: DispatchQueue, completion closure: @escaping (Result) -> Void ) + + func saveSwap( + source: TransactionHistoryItemSource, + chainAssetId: ChainAssetId, + details: PersistSwapDetails, + runningIn queue: DispatchQueue, + completion closure: @escaping (Result) -> Void + ) } final class PersistentExtrinsicService { @@ -86,4 +94,31 @@ extension PersistentExtrinsicService: PersistentExtrinsicServiceProtocol { operationQueue.addOperations(wrapper.allOperations, waitUntilFinished: false) } + + func saveSwap( + source: TransactionHistoryItemSource, + chainAssetId: ChainAssetId, + details: PersistSwapDetails, + runningIn queue: DispatchQueue, + completion closure: @escaping (Result) -> Void + ) { + let wrapper = factory.createSwapSaveOperation( + source: source, + chainAssetId: chainAssetId, + details: details + ) + + wrapper.targetOperation.completionBlock = { + queue.async { + do { + try wrapper.targetOperation.extractNoCancellableResultData() + closure(.success(())) + } catch { + closure(.failure(error)) + } + } + } + + operationQueue.addOperations(wrapper.allOperations, waitUntilFinished: false) + } } diff --git a/novawallet/Common/Services/RemoteSubscription/Evm/ERC20/ContractTransactionHistoryUpdater.swift b/novawallet/Common/Services/RemoteSubscription/Evm/ERC20/ContractTransactionHistoryUpdater.swift index 0f8425efbe..5d1821cff3 100644 --- a/novawallet/Common/Services/RemoteSubscription/Evm/ERC20/ContractTransactionHistoryUpdater.swift +++ b/novawallet/Common/Services/RemoteSubscription/Evm/ERC20/ContractTransactionHistoryUpdater.swift @@ -85,10 +85,12 @@ final class ContractTransactionHistoryUpdater { txHash: transactionHashString, timestamp: Int64(Date().timeIntervalSince1970), fee: fee, + feeAssetId: nil, blockNumber: UInt64(event.blockNumber), txIndex: nil, callPath: CallCodingPath.erc20Tranfer, - call: nil + call: nil, + swap: nil ) return [historyItem] diff --git a/novawallet/Common/Services/RemoteSubscription/Evm/Native/EvmNativeTransactionHistoryUpdater.swift b/novawallet/Common/Services/RemoteSubscription/Evm/Native/EvmNativeTransactionHistoryUpdater.swift index e3a2efdbed..d19940f6e8 100644 --- a/novawallet/Common/Services/RemoteSubscription/Evm/Native/EvmNativeTransactionHistoryUpdater.swift +++ b/novawallet/Common/Services/RemoteSubscription/Evm/Native/EvmNativeTransactionHistoryUpdater.swift @@ -70,10 +70,12 @@ final class EvmNativeTransactionHistoryUpdater { txHash: txHash, timestamp: timestamp, fee: fee, + feeAssetId: nil, blockNumber: UInt64(blockNumber), txIndex: nil, callPath: transaction.isNativeTransfer ? .evmNativeTransfer : .evmNativeTransaction, - call: nil + call: nil, + swap: nil ) return [historyItem] diff --git a/novawallet/Common/Services/RemoteSubscription/Substrate/RemoteSubscriptionRequests.swift b/novawallet/Common/Services/RemoteSubscription/Substrate/RemoteSubscriptionRequests.swift index 521bbb2d32..e6f15bc020 100644 --- a/novawallet/Common/Services/RemoteSubscription/Substrate/RemoteSubscriptionRequests.swift +++ b/novawallet/Common/Services/RemoteSubscription/Substrate/RemoteSubscriptionRequests.swift @@ -38,12 +38,30 @@ struct MapSubscriptionRequest: SubscriptionRequestProtocol { let storagePath: StorageCodingPath let localKey: String let keyParamClosure: () throws -> T + let paramEncoder: ((T) throws -> Data)? + + init( + storagePath: StorageCodingPath, + localKey: String, + keyParamClosure: @escaping () throws -> T, + paramEncoder: ((T) throws -> Data)? = nil + ) { + self.storagePath = storagePath + self.localKey = localKey + self.keyParamClosure = keyParamClosure + self.paramEncoder = paramEncoder + } func createKeyEncodingWrapper( using storageKeyFactory: StorageKeyFactoryProtocol, codingFactoryClosure: @escaping () throws -> RuntimeCoderFactoryProtocol ) -> CompoundOperationWrapper { - let encodingOperation = MapKeyEncodingOperation(path: storagePath, storageKeyFactory: storageKeyFactory) + let encodingOperation = MapKeyEncodingOperation( + path: storagePath, + storageKeyFactory: storageKeyFactory, + paramEncoder: paramEncoder + ) + encodingOperation.configurationBlock = { do { let keyParam = try keyParamClosure() diff --git a/novawallet/Common/Services/RemoteSubscription/Substrate/WalletRemoteSubscriptionService.swift b/novawallet/Common/Services/RemoteSubscription/Substrate/WalletRemoteSubscriptionService.swift index 937fa4426a..ad4c0da8a8 100644 --- a/novawallet/Common/Services/RemoteSubscription/Substrate/WalletRemoteSubscriptionService.swift +++ b/novawallet/Common/Services/RemoteSubscription/Substrate/WalletRemoteSubscriptionService.swift @@ -211,7 +211,7 @@ class WalletRemoteSubscriptionService: RemoteSubscriptionService, WalletRemoteSu storagePath: accountStoragePath, localKey: accountLocalKey, keyParamClosure: { (assetId, accountId) }, - param1Encoder: nil, + param1Encoder: StatemineAssetSerializer.subscriptionKeyEncoder(for: assetId), param2Encoder: nil ) @@ -225,7 +225,8 @@ class WalletRemoteSubscriptionService: RemoteSubscriptionService, WalletRemoteSu let detailsRequest = MapSubscriptionRequest( storagePath: detailsStoragePath, localKey: detailsLocalKey, - keyParamClosure: { StringScaleMapper(value: assetId) } + keyParamClosure: { assetId }, + paramEncoder: StatemineAssetSerializer.subscriptionKeyEncoder(for: assetId) ) let handlingFactory = AssetsSubscriptionHandlingFactory( diff --git a/novawallet/Common/Services/RemoteSubscription/Substrate/WalletRemoteSubscriptionWrapper.swift b/novawallet/Common/Services/RemoteSubscription/Substrate/WalletRemoteSubscriptionWrapper.swift index 512e8dccd2..8eb7e6a6e9 100644 --- a/novawallet/Common/Services/RemoteSubscription/Substrate/WalletRemoteSubscriptionWrapper.swift +++ b/novawallet/Common/Services/RemoteSubscription/Substrate/WalletRemoteSubscriptionWrapper.swift @@ -146,9 +146,9 @@ extension WalletRemoteSubscriptionWrapper: WalletRemoteSubscriptionWrapperProtoc chainFormat: chainAsset.chain.chainFormat, completion: completion ) - case let .statemine(extras): + case let .statemine(info): return subscribeAssets( - using: extras, + using: .init(info: info), accountId: accountId, chainAssetId: chainAsset.chainAssetId, completion: completion @@ -182,11 +182,11 @@ extension WalletRemoteSubscriptionWrapper: WalletRemoteSubscriptionWrapperProtoc queue: .main, closure: completion ) - case let .statemine(extras): + case let .statemine(info): remoteSubscriptionService.detachFromAsset( for: subscriptionId, accountId: accountId, - extras: extras, + extras: .init(info: info), chainId: chainAssetId.chainId, queue: .main, closure: completion diff --git a/novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessing.swift b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessing.swift similarity index 88% rename from novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessing.swift rename to novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessing.swift index 6262a58aec..3a063da2ad 100644 --- a/novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessing.swift +++ b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessing.swift @@ -2,18 +2,6 @@ import Foundation import SubstrateSdk import BigInt -struct ExtrinsicProcessingResult { - let sender: AccountId - let callPath: CallCodingPath - let call: JSON - let extrinsicHash: Data? - let fee: BigUInt? - let peerId: AccountId? - let amount: BigUInt? - let isSuccess: Bool - let assetId: UInt32 -} - protocol ExtrinsicProcessing { func process( extrinsicIndex: UInt32, @@ -60,7 +48,7 @@ extension ExtrinsicProcessor: ExtrinsicProcessing { extrinsicIndex: extrinsicIndex, extrinsic: extrinsic, eventRecords: eventRecords, - metadata: coderFactory.metadata, + codingFactory: coderFactory, context: runtimeJsonContext ) { return processingResult @@ -84,6 +72,15 @@ extension ExtrinsicProcessor: ExtrinsicProcessing { return processingResult } + if let processingResult = matchAssetHubSwap( + extrinsicIndex: extrinsicIndex, + extrinsic: extrinsic, + eventRecords: eventRecords, + codingFactory: coderFactory + ) { + return processingResult + } + return matchExtrinsic( extrinsicIndex: extrinsicIndex, extrinsic: extrinsic, diff --git a/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessingResult.swift b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessingResult.swift new file mode 100644 index 0000000000..28ee3d1d03 --- /dev/null +++ b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessingResult.swift @@ -0,0 +1,24 @@ +import Foundation +import BigInt +import SubstrateSdk + +struct ExtrinsicProcessingResult { + struct Swap { + let assetIdIn: AssetModel.Id + let assetIdOut: AssetModel.Id + let amountIn: BigUInt + let amountOut: BigUInt + } + + let sender: AccountId + let callPath: CallCodingPath + let call: JSON + let extrinsicHash: Data? + let fee: BigUInt? + let feeAssetId: AssetModel.Id? + let peerId: AccountId? + let amount: BigUInt? + let isSuccess: Bool + let assetId: AssetModel.Id? + let swap: Swap? +} diff --git a/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+CustomFee.swift b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+CustomFee.swift new file mode 100644 index 0000000000..85e0424c8e --- /dev/null +++ b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+CustomFee.swift @@ -0,0 +1,25 @@ +import Foundation +import SubstrateSdk + +extension ExtrinsicProcessor { + func findFeeInCustomAsset( + in events: [EventRecord], + codingFactory: RuntimeCoderFactoryProtocol + ) throws -> AssetTxPaymentPallet.AssetTxFeePaid? { + let context = codingFactory.createRuntimeJsonContext() + let metadata = codingFactory.metadata + + let optFeeRecord = events.first { record in + guard let eventPath = metadata.createEventCodingPath(from: record.event) else { + return false + } + + return eventPath == AssetTxPaymentPallet.assetTxFeePaidEvent + } + + return try optFeeRecord?.event.params.map( + to: AssetTxPaymentPallet.AssetTxFeePaid.self, + with: context.toRawContext() + ) + } +} diff --git a/novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessor+Events.swift b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+Events.swift similarity index 100% rename from novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessor+Events.swift rename to novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+Events.swift diff --git a/novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessor+Fee.swift b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+Fee.swift similarity index 100% rename from novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessor+Fee.swift rename to novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+Fee.swift diff --git a/novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessor+Matching.swift b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+Matching.swift similarity index 94% rename from novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessor+Matching.swift rename to novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+Matching.swift index 70b9dc2a1d..554a8e2464 100644 --- a/novawallet/Common/Services/WebSocketService/StorageSubscription/ExtrinsicProcessor+Matching.swift +++ b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+Matching.swift @@ -14,7 +14,7 @@ private typealias AssetsParsingResult = ( callPath: CallCodingPath, isAccountMatched: Bool, callAccountId: AccountId?, - callAssetId: String, + callAssetId: JSON, callAmount: BigUInt ) @@ -100,10 +100,12 @@ extension ExtrinsicProcessor { call: extrinsic.call, extrinsicHash: nil, fee: fee, + feeAssetId: nil, peerId: peerId, amount: result.callAmount, isSuccess: status, - assetId: asset.assetId + assetId: asset.assetId, + swap: nil ) } catch { @@ -225,10 +227,12 @@ extension ExtrinsicProcessor { call: extrinsic.call, extrinsicHash: executedValue.transactionHash, fee: fee, + feeAssetId: nil, peerId: executedValue.to, amount: nil, isSuccess: executedValue.isSuccess, - assetId: assetId + assetId: assetId, + swap: nil ) } catch { return nil @@ -281,10 +285,12 @@ extension ExtrinsicProcessor { call: extrinsic.call, extrinsicHash: nil, fee: fee, + feeAssetId: nil, peerId: nil, amount: nil, isSuccess: isSuccess, - assetId: assetId + assetId: assetId, + swap: nil ) } catch { @@ -296,10 +302,12 @@ extension ExtrinsicProcessor { extrinsicIndex: UInt32, extrinsic: Extrinsic, eventRecords: [EventRecord], - metadata: RuntimeMetadataProtocol, + codingFactory: RuntimeCoderFactoryProtocol, context: RuntimeJsonContext ) -> ExtrinsicProcessingResult? { do { + let metadata = codingFactory.metadata + let rawContext = context.toRawContext() let maybeAddress = extrinsic.signature?.address let maybeSender = try maybeAddress?.map(to: MultiAddress.self, with: rawContext).accountId @@ -307,7 +315,6 @@ extension ExtrinsicProcessor { let result = try parseAssetsExtrinsic(extrinsic, sender: maybeSender, context: context) guard - result.callPath.isAssetsTransfer, result.isAccountMatched, let sender = maybeSender else { return nil @@ -329,6 +336,13 @@ extension ExtrinsicProcessor { let peerId = accountId == sender ? result.callAccountId : sender + let remotePalletName = result.callPath.moduleName + let remoteAssetId = try StatemineAssetSerializer.encode( + assetId: result.callAssetId, + palletName: remotePalletName, + codingFactory: codingFactory + ) + let maybeAsset = chain.assets.first { asset in guard asset.type == AssetType.statemine.rawValue, @@ -336,7 +350,9 @@ extension ExtrinsicProcessor { return false } - return typeExtra.assetId == result.callAssetId + let localPalletName = typeExtra.palletName ?? PalletAssets.name + + return remotePalletName == localPalletName && typeExtra.assetId == remoteAssetId } guard let asset = maybeAsset else { @@ -349,10 +365,12 @@ extension ExtrinsicProcessor { call: extrinsic.call, extrinsicHash: nil, fee: fee, + feeAssetId: nil, peerId: peerId, amount: result.callAmount, isSuccess: isSuccess, - assetId: asset.assetId + assetId: asset.assetId, + swap: nil ) } catch { @@ -431,10 +449,12 @@ extension ExtrinsicProcessor { call: extrinsic.call, extrinsicHash: nil, fee: fee, + feeAssetId: nil, peerId: peerId, amount: result.callAmount, isSuccess: isSuccess, - assetId: assetId + assetId: assetId, + swap: nil ) } catch { @@ -570,10 +590,12 @@ extension ExtrinsicProcessor { call: extrinsic.call, extrinsicHash: nil, fee: fee, + feeAssetId: nil, peerId: peerId, amount: call.args.value, isSuccess: status, - assetId: assetId + assetId: assetId, + swap: nil ) } catch { diff --git a/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+SwapMatching.swift b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+SwapMatching.swift new file mode 100644 index 0000000000..3ba1bb97fd --- /dev/null +++ b/novawallet/Common/Services/TransactionSubscription/ExtrinsicProcessor+SwapMatching.swift @@ -0,0 +1,299 @@ +import Foundation +import BigInt +import SubstrateSdk + +private struct SwapExtrinsicCallArgs { + let receiver: AccountId + let amountIn: BigUInt + let amountOut: BigUInt + let path: [AssetConversionPallet.AssetId] +} + +private struct SwapExtrinsicParsingResult { + let receiver: AccountId + let assetIdIn: UInt32 + let amountIn: BigUInt + let assetIdOut: UInt32 + let amountOut: BigUInt + let callPath: CallCodingPath + let call: JSON + let customFee: AssetTxPaymentPallet.AssetTxFeePaid? + let isSuccess: Bool +} + +extension ExtrinsicProcessor { + func matchAssetHubSwap( + extrinsicIndex: UInt32, + extrinsic: Extrinsic, + eventRecords: [EventRecord], + codingFactory: RuntimeCoderFactoryProtocol + ) -> ExtrinsicProcessingResult? { + do { + guard let swapResult = try parseAssetHubSwapExtrinsic( + extrinsic, + extrinsicIndex: extrinsicIndex, + eventRecords: eventRecords, + codingFactory: codingFactory + ) else { + return nil + } + + let fee: BigUInt + let feeAssetId: AssetModel.Id? + + let context = codingFactory.createRuntimeJsonContext() + + let maybeSender: AccountId? = try extrinsic.signature?.address.map( + to: MultiAddress.self, + with: context.toRawContext() + ).accountId + + guard let sender = maybeSender else { + return nil + } + + if + let customFee = swapResult.customFee, + let remoteAssetId = try? customFee.assetId.map( + to: AssetConversionPallet.AssetId.self, + with: context.toRawContext() + ), + let localAsset = AssetHubTokensConverter.convertFromMultilocationToLocal( + remoteAssetId, + chain: chain, + conversionClosure: AssetHubTokensConverter.createPoolAssetToLocalClosure( + for: chain, + codingFactory: codingFactory + ) + ) { + fee = customFee.actualFee + feeAssetId = localAsset.asset.assetId + } else { + let optNativeFee = findFee( + for: extrinsicIndex, + sender: sender, + eventRecords: eventRecords, + metadata: codingFactory.metadata, + runtimeJsonContext: context + ) + + guard let nativeFee = optNativeFee else { + return nil + } + + fee = nativeFee + feeAssetId = chain.utilityAsset()?.assetId + } + + return .init( + sender: sender, + callPath: swapResult.callPath, + call: swapResult.call, + extrinsicHash: nil, + fee: fee, + feeAssetId: feeAssetId, + peerId: swapResult.receiver, + amount: nil, + isSuccess: swapResult.isSuccess, + assetId: swapResult.assetIdIn, + swap: .init( + assetIdIn: swapResult.assetIdIn, + assetIdOut: swapResult.assetIdOut, + amountIn: swapResult.amountIn, + amountOut: swapResult.amountOut + ) + ) + + } catch { + return nil + } + } + + private func parseAssetHubSwapExtrinsic( + _ extrinsic: Extrinsic, + extrinsicIndex: UInt32, + eventRecords: [EventRecord], + codingFactory: RuntimeCoderFactoryProtocol + ) throws -> SwapExtrinsicParsingResult? { + let context = codingFactory.createRuntimeJsonContext() + + guard + let call = try? extrinsic.call.map(to: RuntimeCall.self, with: context.toRawContext()), + AssetConversionPallet.isSwap(.init(moduleName: call.moduleName, callName: call.callName)) else { + return nil + } + + let customFee = try findFeeInCustomAsset( + in: eventRecords, + codingFactory: codingFactory + ) + + guard + let isSuccess = matchStatus( + for: extrinsicIndex, + eventRecords: eventRecords, + metadata: codingFactory.metadata + ) else { + return nil + } + + if isSuccess { + return try findSuccessAssetHubSwapResult( + from: call, + eventRecords: eventRecords, + customFee: customFee, + codingFactory: codingFactory + ) + } else { + return try findFailedAssetHubSwapResult( + from: call, + customFee: customFee, + codingFactory: codingFactory + ) + } + } + + private func findSuccessAssetHubSwapResult( + from call: RuntimeCall, + eventRecords: [EventRecord], + customFee: AssetTxPaymentPallet.AssetTxFeePaid?, + codingFactory: RuntimeCoderFactoryProtocol + ) throws -> SwapExtrinsicParsingResult? { + let callPath = CallCodingPath(moduleName: call.moduleName, callName: call.callName) + + let context = codingFactory.createRuntimeJsonContext() + let metadata = codingFactory.metadata + + let swapEvents: [AssetConversionPallet.SwapExecutedEvent] = eventRecords.compactMap { record in + guard + let eventPath = metadata.createEventCodingPath(from: record.event), + AssetConversionPallet.swapExecutedEvent == eventPath else { + return nil + } + + let type = AssetConversionPallet.SwapExecutedEvent.self + return try? record.event.params.map(to: type, with: context.toRawContext()) + } + + guard + let swap = findSwap(swapEvents, customFee: customFee), + let remoteAssetIn = swap.path.first, + let remoteAssetOut = swap.path.last + else { + return nil + } + + let conversionClosure = AssetHubTokensConverter.createPoolAssetToLocalClosure( + for: chain, + codingFactory: codingFactory + ) + + guard + let assetIn = AssetHubTokensConverter.convertFromMultilocationToLocal( + remoteAssetIn, + chain: chain, + conversionClosure: conversionClosure + ), + let assetOut = AssetHubTokensConverter.convertFromMultilocationToLocal( + remoteAssetOut, + chain: chain, + conversionClosure: conversionClosure + ) else { + return nil + } + + return .init( + receiver: swap.sendTo, + assetIdIn: assetIn.chainAssetId.assetId, + amountIn: swap.amountIn, + assetIdOut: assetOut.chainAssetId.assetId, + amountOut: swap.amountOut, + callPath: callPath, + call: call.args, + customFee: customFee, + isSuccess: true + ) + } + + private func findFailedAssetHubSwapResult( + from call: RuntimeCall, + customFee: AssetTxPaymentPallet.AssetTxFeePaid?, + codingFactory: RuntimeCoderFactoryProtocol + ) throws -> SwapExtrinsicParsingResult? { + let callPath = CallCodingPath(moduleName: call.moduleName, callName: call.callName) + + let conversionClosure = AssetHubTokensConverter.createPoolAssetToLocalClosure( + for: chain, + codingFactory: codingFactory + ) + + let context = codingFactory.createRuntimeJsonContext() + let args: SwapExtrinsicCallArgs + + switch callPath { + case AssetConversionPallet.swapExactTokenForTokensPath: + let type = AssetConversionPallet.SwapExactTokensForTokensCall.self + let call = try call.args.map(to: type, with: context.toRawContext()) + + args = .init(receiver: call.sendTo, amountIn: call.amountIn, amountOut: call.amountOutMin, path: call.path) + + case AssetConversionPallet.swapTokenForExactTokens: + let type = AssetConversionPallet.SwapTokensForExactTokensCall.self + let call = try call.args.map(to: type, with: context.toRawContext()) + + args = .init(receiver: call.sendTo, amountIn: call.amountInMax, amountOut: call.amountOut, path: call.path) + default: + return nil + } + + guard + let remoteAssetIn = args.path.first, + let remoteAssetOut = args.path.last, + let assetIn = AssetHubTokensConverter.convertFromMultilocationToLocal( + remoteAssetIn, + chain: chain, + conversionClosure: conversionClosure + ), + let assetOut = AssetHubTokensConverter.convertFromMultilocationToLocal( + remoteAssetOut, + chain: chain, + conversionClosure: conversionClosure + ) else { + return nil + } + + return .init( + receiver: args.receiver, + assetIdIn: assetIn.asset.assetId, + amountIn: args.amountIn, + assetIdOut: assetOut.asset.assetId, + amountOut: args.amountOut, + callPath: callPath, + call: call.args, + customFee: customFee, + isSuccess: false + ) + } + + private func findSwap( + _ swapEvents: [AssetConversionPallet.SwapExecutedEvent], + customFee: AssetTxPaymentPallet.AssetTxFeePaid? + ) -> AssetConversionPallet.SwapExecutedEvent? { + guard customFee != nil else { + return swapEvents.first + } + + let optFeeSwap = swapEvents.first + let swapsAfterFee = swapEvents.dropFirst() + + guard + let feeSwap = optFeeSwap, + let targetSwap = swapsAfterFee.first, + let feeAssetOut = feeSwap.path.last, + case .native = AssetHubTokensConverter.convertFromMultilocation(feeAssetOut, chain: chain) else { + return nil + } + + return targetSwap + } +} diff --git a/novawallet/Common/Services/WebSocketService/StorageSubscription/TransactionSubscription.swift b/novawallet/Common/Services/TransactionSubscription/TransactionSubscription.swift similarity index 100% rename from novawallet/Common/Services/WebSocketService/StorageSubscription/TransactionSubscription.swift rename to novawallet/Common/Services/TransactionSubscription/TransactionSubscription.swift diff --git a/novawallet/Common/Services/WebSocketService/StorageSubscription/BatchStorageSubscriptionResult.swift b/novawallet/Common/Services/WebSocketService/StorageSubscription/BatchStorageSubscriptionResult.swift index a8f1dc2331..daf5e30b39 100644 --- a/novawallet/Common/Services/WebSocketService/StorageSubscription/BatchStorageSubscriptionResult.swift +++ b/novawallet/Common/Services/WebSocketService/StorageSubscription/BatchStorageSubscriptionResult.swift @@ -18,3 +18,17 @@ protocol BatchStorageSubscriptionResult { context: [CodingUserInfoKey: Any]? ) throws } + +struct BatchStorageSubscriptionRawResult: BatchStorageSubscriptionResult { + let values: [BatchStorageSubscriptionResultValue] + let blockHashJson: JSON + + init( + values: [BatchStorageSubscriptionResultValue], + blockHashJson: JSON, + context _: [CodingUserInfoKey: Any]? + ) throws { + self.values = values + self.blockHashJson = blockHashJson + } +} diff --git a/novawallet/Common/Storage/EntityToModel/ChainModelMapper.swift b/novawallet/Common/Storage/EntityToModel/ChainModelMapper.swift index bd46ab741d..db071febb3 100644 --- a/novawallet/Common/Storage/EntityToModel/ChainModelMapper.swift +++ b/novawallet/Common/Storage/EntityToModel/ChainModelMapper.swift @@ -301,6 +301,10 @@ final class ChainModelMapper { options.append(.noSubstrateRuntime) } + if entity.hasSwapHub { + options.append(.swapHub) + } + return !options.isEmpty ? options : nil } } @@ -379,6 +383,7 @@ extension ChainModelMapper: CoreDataMapperProtocol { entity.hasGovernanceV1 = model.hasGovernanceV1 entity.hasGovernance = model.hasGovernanceV2 entity.noSubstrateRuntime = model.noSubstrateRuntime + entity.hasSwapHub = model.hasSwapHub entity.order = model.order entity.nodeSwitchStrategy = model.nodeSwitchStrategy.rawValue entity.additional = try model.additional.map { diff --git a/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/.xccurrentversion b/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/.xccurrentversion index 46d2cdef68..9ead670ac5 100644 --- a/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/.xccurrentversion +++ b/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - SubstrateDataModel19.xcdatamodel + SubstrateDataModel21.xcdatamodel diff --git a/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/SubstrateDataModel20.xcdatamodel/contents b/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/SubstrateDataModel20.xcdatamodel/contents new file mode 100644 index 0000000000..7c9e246325 --- /dev/null +++ b/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/SubstrateDataModel20.xcdatamodel/contents @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/SubstrateDataModel21.xcdatamodel/contents b/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/SubstrateDataModel21.xcdatamodel/contents new file mode 100644 index 0000000000..d042fae025 --- /dev/null +++ b/novawallet/Common/Storage/SubstrateDataModel.xcdatamodeld/SubstrateDataModel21.xcdatamodel/contents @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/novawallet/Common/Storage/SubstrateDataStorageFacade.swift b/novawallet/Common/Storage/SubstrateDataStorageFacade.swift index 6b6e13d478..0c16dde937 100644 --- a/novawallet/Common/Storage/SubstrateDataStorageFacade.swift +++ b/novawallet/Common/Storage/SubstrateDataStorageFacade.swift @@ -4,7 +4,7 @@ import CoreData enum SubstrateStorageParams { static let databaseName = "SubstrateDataModel.sqlite" static let modelDirectory: String = "SubstrateDataModel.momd" - static let modelVersion: SubstrateStorageVersion = .version19 + static let modelVersion: SubstrateStorageVersion = .version21 static let storageDirectoryURL: URL = { let baseURL = FileManager.default.urls( diff --git a/novawallet/Common/Substrate/Calls/AssetConversionPallet/AssetConversionPallet+Call.swift b/novawallet/Common/Substrate/Calls/AssetConversionPallet/AssetConversionPallet+Call.swift new file mode 100644 index 0000000000..821e901360 --- /dev/null +++ b/novawallet/Common/Substrate/Calls/AssetConversionPallet/AssetConversionPallet+Call.swift @@ -0,0 +1,69 @@ +import Foundation +import SubstrateSdk +import BigInt + +extension AssetConversionPallet { + static var swapExactTokenForTokensPath: CallCodingPath { + CallCodingPath(moduleName: AssetConversionPallet.name, callName: "swap_exact_tokens_for_tokens") + } + + static var swapTokenForExactTokens: CallCodingPath { + CallCodingPath(moduleName: AssetConversionPallet.name, callName: "swap_tokens_for_exact_tokens") + } + + static func isSwap(_ callPath: CallCodingPath) -> Bool { + [ + AssetConversionPallet.swapExactTokenForTokensPath, + AssetConversionPallet.swapTokenForExactTokens + ].contains(callPath) + } + + static func callPath(for direction: AssetConversion.Direction) -> CallCodingPath { + switch direction { + case .sell: + return AssetConversionPallet.swapExactTokenForTokensPath + case .buy: + return AssetConversionPallet.swapTokenForExactTokens + } + } + + struct SwapExactTokensForTokensCall: Codable { + enum CodingKeys: String, CodingKey { + case path + case amountIn = "amount_in" + case amountOutMin = "amount_out_min" + case sendTo = "send_to" + case keepAlive = "keep_alive" + } + + let path: [AssetConversionPallet.AssetId] + @StringCodable var amountIn: BigUInt + @StringCodable var amountOutMin: BigUInt + @BytesCodable var sendTo: AccountId + let keepAlive: Bool + + func runtimeCall(for module: String) -> RuntimeCall { + RuntimeCall(moduleName: module, callName: "swap_exact_tokens_for_tokens", args: self) + } + } + + struct SwapTokensForExactTokensCall: Codable { + enum CodingKeys: String, CodingKey { + case path + case amountOut = "amount_out" + case amountInMax = "amount_in_max" + case sendTo = "send_to" + case keepAlive = "keep_alive" + } + + let path: [AssetConversionPallet.AssetId] + @StringCodable var amountOut: BigUInt + @StringCodable var amountInMax: BigUInt + @BytesCodable var sendTo: AccountId + let keepAlive: Bool + + func runtimeCall(for module: String) -> RuntimeCall { + RuntimeCall(moduleName: module, callName: "swap_tokens_for_exact_tokens", args: self) + } + } +} diff --git a/novawallet/Common/Substrate/Calls/AssetConversionPallet/AssetConversionPallet+Event.swift b/novawallet/Common/Substrate/Calls/AssetConversionPallet/AssetConversionPallet+Event.swift new file mode 100644 index 0000000000..c915c35e94 --- /dev/null +++ b/novawallet/Common/Substrate/Calls/AssetConversionPallet/AssetConversionPallet+Event.swift @@ -0,0 +1,27 @@ +import Foundation +import BigInt +import SubstrateSdk + +extension AssetConversionPallet { + static var swapExecutedEvent: EventCodingPath { + .init(moduleName: AssetConversionPallet.name, eventName: "SwapExecuted") + } + + struct SwapExecutedEvent: Codable { + let who: AccountId + let sendTo: AccountId + let path: [AssetId] + let amountIn: BigUInt + let amountOut: BigUInt + + init(from decoder: Decoder) throws { + var unkeyedContainer = try decoder.unkeyedContainer() + + who = try unkeyedContainer.decode(BytesCodable.self).wrappedValue + sendTo = try unkeyedContainer.decode(BytesCodable.self).wrappedValue + path = try unkeyedContainer.decode([AssetId].self) + amountIn = try unkeyedContainer.decode(StringScaleMapper.self).value + amountOut = try unkeyedContainer.decode(StringScaleMapper.self).value + } + } +} diff --git a/novawallet/Common/Substrate/Calls/Assets/PalletAssets+Call.swift b/novawallet/Common/Substrate/Calls/Assets/PalletAssets+Call.swift new file mode 100644 index 0000000000..d7014c0c58 --- /dev/null +++ b/novawallet/Common/Substrate/Calls/Assets/PalletAssets+Call.swift @@ -0,0 +1,52 @@ +import Foundation + +extension PalletAssets { + static let knownPalletNames: [String] = [ + PalletAssets.name, + "LocalAssets", + "ForeignAssets" + ] + + static func assetsTransfer(for palletName: String?) -> CallCodingPath { + CallCodingPath(moduleName: palletName ?? PalletAssets.name, callName: "transfer") + } + + static func assetsTransferKeepAlive(for palletName: String?) -> CallCodingPath { + CallCodingPath(moduleName: palletName ?? PalletAssets.name, callName: "transfer_keep_alive") + } + + static func assetsForceTransfer(for palletName: String?) -> CallCodingPath { + CallCodingPath(moduleName: palletName ?? PalletAssets.name, callName: "force_transfer") + } + + static func assetsTransferAll(for palletName: String?) -> CallCodingPath { + CallCodingPath(moduleName: palletName ?? PalletAssets.name, callName: "transfer_all") + } + + static func possibleTransferCallPaths() -> [CallCodingPath] { + knownPalletNames.map { palletName in + [ + assetsTransfer(for: palletName), + assetsTransferKeepAlive(for: palletName), + assetsForceTransfer(for: palletName), + assetsTransferAll(for: palletName) + ] + }.flatMap { $0 } + } + + static var localAssetsTransfer: CallCodingPath { + CallCodingPath(moduleName: "LocalAssets", callName: "transfer") + } + + static var localAssetsTransferKeepAlive: CallCodingPath { + CallCodingPath(moduleName: "LocalAssets", callName: "transfer_keep_alive") + } + + static var localAssetsForceTransfer: CallCodingPath { + CallCodingPath(moduleName: "LocalAssets", callName: "force_transfer") + } + + static var localAssetsTransferAll: CallCodingPath { + CallCodingPath(moduleName: "LocalAssets", callName: "transfer_all") + } +} diff --git a/novawallet/Common/Substrate/Calls/Common/AssetsTransfer.swift b/novawallet/Common/Substrate/Calls/Common/AssetsTransfer.swift index 40412c070d..7594ac6fbf 100644 --- a/novawallet/Common/Substrate/Calls/Common/AssetsTransfer.swift +++ b/novawallet/Common/Substrate/Calls/Common/AssetsTransfer.swift @@ -9,7 +9,7 @@ struct AssetsTransfer: Codable { case amount } - let assetId: String + let assetId: JSON let target: MultiAddress @StringCodable var amount: BigUInt } diff --git a/novawallet/Common/Substrate/Calls/Common/SubstrateCallFactory.swift b/novawallet/Common/Substrate/Calls/Common/SubstrateCallFactory.swift index eb6890783c..fcf60da1b9 100644 --- a/novawallet/Common/Substrate/Calls/Common/SubstrateCallFactory.swift +++ b/novawallet/Common/Substrate/Calls/Common/SubstrateCallFactory.swift @@ -14,7 +14,7 @@ protocol SubstrateCallFactoryProtocol { func assetsTransfer( to receiver: AccountId, - extras: StatemineAssetExtras, + info: AssetsPalletStorageInfo, amount: BigUInt ) -> RuntimeCall @@ -101,11 +101,11 @@ final class SubstrateCallFactory: SubstrateCallFactoryProtocol { func assetsTransfer( to receiver: AccountId, - extras: StatemineAssetExtras, + info: AssetsPalletStorageInfo, amount: BigUInt ) -> RuntimeCall { - let args = AssetsTransfer(assetId: extras.assetId, target: .accoundId(receiver), amount: amount) - let callCodingPath = CallCodingPath.assetsTransfer(for: extras.palletName) + let args = AssetsTransfer(assetId: info.assetId, target: .accoundId(receiver), amount: amount) + let callCodingPath = PalletAssets.assetsTransfer(for: info.palletName) return RuntimeCall( moduleName: callCodingPath.moduleName, diff --git a/novawallet/Common/Substrate/Calls/UtilityPallet/Utility+Calls.swift b/novawallet/Common/Substrate/Calls/UtilityPallet/Utility+Calls.swift new file mode 100644 index 0000000000..1b3f6379ca --- /dev/null +++ b/novawallet/Common/Substrate/Calls/UtilityPallet/Utility+Calls.swift @@ -0,0 +1,24 @@ +import Foundation +import SubstrateSdk + +extension UtilityPallet { + static var batchPath: CallCodingPath { + CallCodingPath(moduleName: name, callName: "batch") + } + + static var batchAllPath: CallCodingPath { + CallCodingPath(moduleName: name, callName: "batch_all") + } + + static var forceBatchPath: CallCodingPath { + CallCodingPath(moduleName: name, callName: "force_batch") + } + + static func isBatch(path: CallCodingPath) -> Bool { + [batchPath, batchAllPath, forceBatchPath].contains(path) + } + + struct Call: Codable { + let calls: [RuntimeCall] + } +} diff --git a/novawallet/Common/Substrate/Extension/AssetConversionTxPayment.swift b/novawallet/Common/Substrate/Extension/AssetConversionTxPayment.swift new file mode 100644 index 0000000000..94082b9dfb --- /dev/null +++ b/novawallet/Common/Substrate/Extension/AssetConversionTxPayment.swift @@ -0,0 +1,15 @@ +import Foundation +import BigInt +import SubstrateSdk + +class AssetConversionTxPayment: Codable, ExtrinsicExtension { + public static let name: String = "ChargeAssetTxPayment" + + @StringCodable public var tip: BigUInt + let assetId: AssetConversionPallet.AssetId? + + init(tip: BigUInt = 0, assetId: AssetConversionPallet.AssetId? = nil) { + self.tip = tip + self.assetId = assetId + } +} diff --git a/novawallet/Common/Substrate/Extension/ExtrinsicExtension.swift b/novawallet/Common/Substrate/Extension/ExtrinsicExtension.swift index eef7c70cdc..632ea73109 100644 --- a/novawallet/Common/Substrate/Extension/ExtrinsicExtension.swift +++ b/novawallet/Common/Substrate/Extension/ExtrinsicExtension.swift @@ -2,12 +2,18 @@ import Foundation import SubstrateSdk enum DefaultExtrinsicExtension { - static var extensions: [ExtrinsicExtension] { + static func extensions() -> [ExtrinsicExtension] { [ ChargeAssetTxPayment() ] } + static func extensions(payingFeeIn assetId: AssetConversionPallet.AssetId) -> [ExtrinsicExtension] { + [ + AssetConversionTxPayment(assetId: assetId) + ] + } + static func getCoders(for metadata: RuntimeMetadataProtocol) -> [ExtrinsicExtensionCoder] { let extensionName = ChargeAssetTxPayment.name diff --git a/novawallet/Common/Substrate/Types/AccountInfo.swift b/novawallet/Common/Substrate/Types/AccountInfo.swift index 28f80eec87..ca91bbbec3 100644 --- a/novawallet/Common/Substrate/Types/AccountInfo.swift +++ b/novawallet/Common/Substrate/Types/AccountInfo.swift @@ -4,7 +4,12 @@ import BigInt struct AccountInfo: Codable, Equatable { @StringCodable var nonce: UInt32 + @OptionStringCodable var consumers: UInt32? let data: AccountData + + var hasConsumers: Bool { + (consumers ?? 0) > 0 + } } struct AccountData: Codable, Equatable { diff --git a/novawallet/Common/Substrate/Types/AssetConversionPallet/AssetConversionPallet+Path.swift b/novawallet/Common/Substrate/Types/AssetConversionPallet/AssetConversionPallet+Path.swift new file mode 100644 index 0000000000..c85957ffc5 --- /dev/null +++ b/novawallet/Common/Substrate/Types/AssetConversionPallet/AssetConversionPallet+Path.swift @@ -0,0 +1,15 @@ +import Foundation + +extension AssetConversionPallet { + static var poolsPath: StorageCodingPath { + getPoolsPath(for: AssetConversionPallet.name) + } + + static func getPoolsPath(for moduleName: String) -> StorageCodingPath { + StorageCodingPath(moduleName: moduleName, itemName: "Pools") + } + + static func addLiquidityCallPath(for moduleName: String) -> CallCodingPath { + CallCodingPath(moduleName: moduleName, callName: "add_liquidity") + } +} diff --git a/novawallet/Common/Substrate/Types/AssetConversionPallet/AssetConversionPallet.swift b/novawallet/Common/Substrate/Types/AssetConversionPallet/AssetConversionPallet.swift new file mode 100644 index 0000000000..be2c3dd2b1 --- /dev/null +++ b/novawallet/Common/Substrate/Types/AssetConversionPallet/AssetConversionPallet.swift @@ -0,0 +1,44 @@ +import Foundation +import SubstrateSdk +import BigInt + +enum AssetConversionPallet { + static let name = "AssetConversion" + + typealias AssetId = XcmV3.Multilocation + + enum PoolAsset { + case native + case assets(pallet: UInt8, index: BigUInt) + case foreign(AssetId) + case undefined(AssetId) + } + + struct PoolAssetPair { + let asset1: PoolAsset + let asset2: PoolAsset + } + + struct AssetIdPair: JSONListConvertible { + let asset1: AssetId + let asset2: AssetId + + init(jsonList: [JSON], context: [CodingUserInfoKey: Any]?) throws { + let expectedFieldsCount = 1 + let actualFieldsCount = jsonList.count + guard expectedFieldsCount == actualFieldsCount else { + throw JSONListConvertibleError.unexpectedNumberOfItems( + expected: expectedFieldsCount, + actual: actualFieldsCount + ) + } + + guard let poolId = jsonList[0].arrayValue, poolId.count == 2 else { + throw JSONListConvertibleError.unexpectedValue(jsonList[0]) + } + + asset1 = try poolId[0].map(to: AssetId.self, with: context) + asset2 = try poolId[1].map(to: AssetId.self, with: context) + } + } +} diff --git a/novawallet/Common/Substrate/Types/AssetTxPaymentPallet/AssetTxPaymentPallet.swift b/novawallet/Common/Substrate/Types/AssetTxPaymentPallet/AssetTxPaymentPallet.swift new file mode 100644 index 0000000000..ba639e5213 --- /dev/null +++ b/novawallet/Common/Substrate/Types/AssetTxPaymentPallet/AssetTxPaymentPallet.swift @@ -0,0 +1,27 @@ +import Foundation +import BigInt +import SubstrateSdk + +enum AssetTxPaymentPallet { + static let name = "AssetTxPayment" + + static var assetTxFeePaidEvent: EventCodingPath { + .init(moduleName: Self.name, eventName: "AssetTxFeePaid") + } + + struct AssetTxFeePaid: Codable { + let who: AccountId + let tip: BigUInt + let actualFee: BigUInt + let assetId: JSON + + init(from decoder: Decoder) throws { + var unkeyedContainer = try decoder.unkeyedContainer() + + who = try unkeyedContainer.decode(BytesCodable.self).wrappedValue + actualFee = try unkeyedContainer.decode(StringScaleMapper.self).value + tip = try unkeyedContainer.decode(StringScaleMapper.self).value + assetId = try unkeyedContainer.decode(JSON.self) + } + } +} diff --git a/novawallet/Common/Substrate/Types/Assets/PalletAssets.swift b/novawallet/Common/Substrate/Types/Assets/PalletAssets.swift index d5e569ea9e..a6364b3f88 100644 --- a/novawallet/Common/Substrate/Types/Assets/PalletAssets.swift +++ b/novawallet/Common/Substrate/Types/Assets/PalletAssets.swift @@ -1,3 +1,5 @@ import Foundation -enum PalletAssets {} +enum PalletAssets { + static let name = "Assets" +} diff --git a/novawallet/Common/Substrate/Types/CallCodingPath.swift b/novawallet/Common/Substrate/Types/CallCodingPath.swift index 406b0499ea..23728787e2 100644 --- a/novawallet/Common/Substrate/Types/CallCodingPath.swift +++ b/novawallet/Common/Substrate/Types/CallCodingPath.swift @@ -23,16 +23,7 @@ extension CallCodingPath { } var isAssetsTransfer: Bool { - [ - .assetsTransfer(for: nil), - .assetsTransferKeepAlive(for: nil), - .assetsForceTransfer(for: nil), - .assetsTransferAll(for: nil), - .localAssetsTransfer, - .localAssetsTransferKeepAlive, - .localAssetsForceTransfer, - .localAssetsTransferAll - ].contains(self) + PalletAssets.possibleTransferCallPaths().contains(self) } var isTokensTransfer: Bool { @@ -104,38 +95,6 @@ extension CallCodingPath { CallCodingPath(moduleName: "Currencies", callName: "transfer_all") } - static func assetsTransfer(for palletName: String?) -> CallCodingPath { - CallCodingPath(moduleName: palletName ?? "Assets", callName: "transfer") - } - - static func assetsTransferKeepAlive(for palletName: String?) -> CallCodingPath { - CallCodingPath(moduleName: palletName ?? "Assets", callName: "transfer_keep_alive") - } - - static func assetsForceTransfer(for palletName: String?) -> CallCodingPath { - CallCodingPath(moduleName: palletName ?? "Assets", callName: "force_transfer") - } - - static func assetsTransferAll(for palletName: String?) -> CallCodingPath { - CallCodingPath(moduleName: palletName ?? "Assets", callName: "transfer_all") - } - - static var localAssetsTransfer: CallCodingPath { - CallCodingPath(moduleName: "LocalAssets", callName: "transfer") - } - - static var localAssetsTransferKeepAlive: CallCodingPath { - CallCodingPath(moduleName: "LocalAssets", callName: "transfer_keep_alive") - } - - static var localAssetsForceTransfer: CallCodingPath { - CallCodingPath(moduleName: "LocalAssets", callName: "force_transfer") - } - - static var localAssetsTransferAll: CallCodingPath { - CallCodingPath(moduleName: "LocalAssets", callName: "transfer_all") - } - static var ethereumTransact: CallCodingPath { CallCodingPath(moduleName: "Ethereum", callName: "transact") } @@ -164,23 +123,3 @@ extension CallCodingPath { [.slash, .reward, .poolReward, .poolSlash].contains(self) } } - -// MARK: Filter - -extension CallCodingPath { - func matches(filter: WalletHistoryFilter) -> Bool { - if !filter.contains(.transfers), isSubstrateOrEvmTransfer { - return false - } - - if !filter.contains(.rewardsAndSlashes), isAnyStakingRewardOrSlash { - return false - } - - if !filter.contains(.extrinsics), !isSubstrateOrEvmTransfer, !isAnyStakingRewardOrSlash { - return false - } - - return true - } -} diff --git a/novawallet/Common/Substrate/Types/UtilityPallet/UtilityPallet.swift b/novawallet/Common/Substrate/Types/UtilityPallet/UtilityPallet.swift new file mode 100644 index 0000000000..65aba45c5c --- /dev/null +++ b/novawallet/Common/Substrate/Types/UtilityPallet/UtilityPallet.swift @@ -0,0 +1,5 @@ +import Foundation + +enum UtilityPallet { + static let name = "Utility" +} diff --git a/novawallet/Common/Substrate/Types/Xcm/V3/XcmV3.swift b/novawallet/Common/Substrate/Types/Xcm/V3/XcmV3.swift new file mode 100644 index 0000000000..b6988009bc --- /dev/null +++ b/novawallet/Common/Substrate/Types/Xcm/V3/XcmV3.swift @@ -0,0 +1,3 @@ +import Foundation + +enum XcmV3 {} diff --git a/novawallet/Common/Substrate/Types/Xcm/V3/XcmV3Junction.swift b/novawallet/Common/Substrate/Types/Xcm/V3/XcmV3Junction.swift new file mode 100644 index 0000000000..0cb24c39e4 --- /dev/null +++ b/novawallet/Common/Substrate/Types/Xcm/V3/XcmV3Junction.swift @@ -0,0 +1,181 @@ +import Foundation +import BigInt +import SubstrateSdk + +extension XcmV3 { + enum NetworkId: Codable { + static let byGenesisField = "ByGenesis" + static let polkadotField = "Polkadot" + static let kusamaField = "Kusama" + static let westendField = "Westend" + + case byGenesis(Data) + case polkadot + case kusama + case westend + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let type = try container.decode(String.self) + + switch type { + case Self.byGenesisField: + let hash = try container.decode(BytesCodable.self).wrappedValue + self = .byGenesis(hash) + case Self.polkadotField: + self = .polkadot + case Self.kusamaField: + self = .kusama + case Self.westendField: + self = .westend + default: + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported network id: \(type)" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + switch self { + case let .byGenesis(hash): + try container.encode(Self.byGenesisField) + try container.encode(BytesCodable(wrappedValue: hash)) + case .polkadot: + try container.encode(Self.polkadotField) + try container.encode(JSON.null) + case .kusama: + try container.encode(Self.kusamaField) + try container.encode(JSON.null) + case .westend: + try container.encode(Self.westendField) + try container.encode(JSON.null) + } + } + } + + struct AccountId32Value: Codable { + enum CodingKeys: String, CodingKey { + case network + case accountId = "id" + } + + let network: NetworkId? + @BytesCodable var accountId: AccountId + } + + struct AccountId20Value: Codable { + let network: NetworkId? + @BytesCodable var key: AccountId + } + + struct AccountIndexValue: Codable { + let network: NetworkId? + @StringCodable var index: UInt64 + } + + enum Junction: Codable { + static let parachainField = "Parachain" + static let accountId32Field = "AccountId32" + static let accountIndex64Field = "AccountIndex64" + static let accountKey20Field = "AccountKey20" + static let palletInstanceField = "PalletInstance" + static let generalIndexField = "GeneralIndex" + static let generalKeyField = "GeneralKey" + static let onlyChildKey = "OnlyChild" + static let globalConsensusField = "GlobalConsensus" + + case parachain(_ paraId: ParaId) + case accountId32(AccountId32Value) + case accountIndex64(AccountIndexValue) + case accountKey20(AccountId20Value) + case palletInstance(_ index: UInt8) + case generalIndex(_ index: BigUInt) + case generalKey(_ key: Data) + case onlyChild + case globalConsensus(NetworkId) + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let type = try container.decode(String.self) + + switch type { + case Self.parachainField: + let paraId = try container.decode(StringScaleMapper.self).value + self = .parachain(paraId) + case Self.accountId32Field: + let accountId = try container.decode(AccountId32Value.self) + self = .accountId32(accountId) + case Self.accountIndex64Field: + let accountIndex = try container.decode(AccountIndexValue.self) + self = .accountIndex64(accountIndex) + case Self.accountKey20Field: + let accountKey = try container.decode(AccountId20Value.self) + self = .accountKey20(accountKey) + case Self.palletInstanceField: + let index = try container.decode(StringScaleMapper.self).value + self = .palletInstance(index) + case Self.generalKeyField: + let key = try container.decode(BytesCodable.self).wrappedValue + self = .generalKey(key) + case Self.generalIndexField: + let index = try container.decode(StringScaleMapper.self).value + self = .generalIndex(index) + case Self.onlyChildKey: + self = .onlyChild + case Self.globalConsensusField: + let network = try container.decode(NetworkId.self) + self = .globalConsensus(network) + default: + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported junction: \(type)" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + switch self { + case let .parachain(paraId): + try container.encode(Self.parachainField) + try container.encode(StringScaleMapper(value: paraId)) + case let .accountId32(value): + try container.encode(Self.accountId32Field) + try container.encode(value) + case let .accountIndex64(value): + try container.encode(Self.accountIndex64Field) + try container.encode(value) + case let .accountKey20(value): + try container.encode(Self.accountKey20Field) + try container.encode(value) + case let .palletInstance(index): + try container.encode(Self.palletInstanceField) + try container.encode(StringScaleMapper(value: index)) + case let .generalIndex(index): + try container.encode(Self.generalIndexField) + try container.encode(StringScaleMapper(value: index)) + case let .generalKey(key): + try container.encode(Self.generalKeyField) + try container.encode(BytesCodable(wrappedValue: key)) + case .onlyChild: + try container.encode(Self.onlyChildKey) + try container.encode(JSON.null) + case let .globalConsensus(network): + try container.encode(Self.globalConsensusField) + try container.encode(network) + } + } + } + + typealias Junctions = Xcm.Junctions +} diff --git a/novawallet/Common/Substrate/Types/Xcm/V3/XcmV3Multilocation.swift b/novawallet/Common/Substrate/Types/Xcm/V3/XcmV3Multilocation.swift new file mode 100644 index 0000000000..76c02e43a4 --- /dev/null +++ b/novawallet/Common/Substrate/Types/Xcm/V3/XcmV3Multilocation.swift @@ -0,0 +1,9 @@ +import Foundation +import SubstrateSdk + +extension XcmV3 { + struct Multilocation: Codable { + @StringCodable var parents: UInt8 + let interior: XcmV3.Junctions + } +} diff --git a/novawallet/Common/Substrate/Types/Xcm/XcmJunction.swift b/novawallet/Common/Substrate/Types/Xcm/XcmJunction.swift index 334ab8cf12..fadc54df65 100644 --- a/novawallet/Common/Substrate/Types/Xcm/XcmJunction.swift +++ b/novawallet/Common/Substrate/Types/Xcm/XcmJunction.swift @@ -3,7 +3,63 @@ import BigInt import SubstrateSdk extension Xcm { - struct AccountId32Value: Encodable { + enum NetworkId: Codable { + static let anyField = "Any" + static let namedField = "Named" + static let polkadotField = "Polkadot" + static let kusamaField = "Kusama" + + case any + case named(_ data: Data) + case polkadot + case kusama + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let type = try container.decode(String.self) + + switch type { + case Self.anyField: + self = .any + case Self.namedField: + let data = try container.decode(BytesCodable.self).wrappedValue + self = .named(data) + case Self.polkadotField: + self = .polkadot + case Self.kusamaField: + self = .kusama + default: + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported network id: \(type)" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + + switch self { + case .any: + try container.encode(Self.anyField) + try container.encode(JSON.null) + case let .named(data): + try container.encode(Self.namedField) + try container.encode(BytesCodable(wrappedValue: data)) + case .polkadot: + try container.encode(Self.polkadotField) + try container.encode(JSON.null) + case .kusama: + try container.encode(Self.kusamaField) + try container.encode(JSON.null) + } + } + } + + struct AccountId32Value: Codable { enum CodingKeys: String, CodingKey { case network case accountId = "id" @@ -13,17 +69,26 @@ extension Xcm { @BytesCodable var accountId: AccountId } - struct AccountId20Value: Encodable { + struct AccountId20Value: Codable { let network: NetworkId @BytesCodable var key: AccountId } - struct AccountIndexValue: Encodable { + struct AccountIndexValue: Codable { let network: NetworkId @StringCodable var index: UInt64 } - enum Junction: Encodable { + enum Junction: Codable { + static let parachainField = "Parachain" + static let accountId32Field = "AccountId32" + static let accountIndex64Field = "AccountIndex64" + static let accountKey20Field = "AccountKey20" + static let palletInstanceField = "PalletInstance" + static let generalIndexField = "GeneralIndex" + static let generalKeyField = "GeneralKey" + static let onlyChildKey = "OnlyChild" + case parachain(_ paraId: ParaId) case accountId32(AccountId32Value) case accountIndex64(AccountIndexValue) @@ -33,48 +98,136 @@ extension Xcm { case generalKey(_ key: Data) case onlyChild + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let type = try container.decode(String.self) + + switch type { + case Self.parachainField: + let paraId = try container.decode(StringScaleMapper.self).value + self = .parachain(paraId) + case Self.accountId32Field: + let accountId = try container.decode(AccountId32Value.self) + self = .accountId32(accountId) + case Self.accountIndex64Field: + let accountIndex = try container.decode(AccountIndexValue.self) + self = .accountIndex64(accountIndex) + case Self.accountKey20Field: + let accountKey = try container.decode(AccountId20Value.self) + self = .accountKey20(accountKey) + case Self.palletInstanceField: + let index = try container.decode(StringScaleMapper.self).value + self = .palletInstance(index) + case Self.generalIndexField: + let index = try container.decode(StringScaleMapper.self).value + self = .generalIndex(index) + case Self.generalKeyField: + let key = try container.decode(BytesCodable.self).wrappedValue + self = .generalKey(key) + case Self.onlyChildKey: + self = .onlyChild + default: + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unsupported junction: \(type)" + ) + ) + } + } + func encode(to encoder: Encoder) throws { var container = encoder.unkeyedContainer() switch self { case let .parachain(paraId): - try container.encode("Parachain") + try container.encode(Self.parachainField) try container.encode(StringScaleMapper(value: paraId)) case let .accountId32(value): - try container.encode("AccountId32") + try container.encode(Self.accountId32Field) try container.encode(value) case let .accountIndex64(value): - try container.encode("AccountIndex64") + try container.encode(Self.accountIndex64Field) try container.encode(value) case let .accountKey20(value): - try container.encode("AccountKey20") + try container.encode(Self.accountKey20Field) try container.encode(value) case let .palletInstance(index): - try container.encode("PalletInstance") + try container.encode(Self.palletInstanceField) try container.encode(StringScaleMapper(value: index)) case let .generalIndex(index): - try container.encode("GeneralIndex") + try container.encode(Self.generalIndexField) try container.encode(StringScaleMapper(value: index)) case let .generalKey(key): - try container.encode("GeneralKey") + try container.encode(Self.generalKeyField) try container.encode(BytesCodable(wrappedValue: key)) case .onlyChild: - try container.encode("OnlyChild") + try container.encode(Self.onlyChildKey) try container.encode(JSON.null) } } } - struct Junctions: Encodable { - let items: [Xcm.Junction] + enum JunctionsConstants { + static let hereField = "Here" + static let junctionPrefix = "X" + } + + struct Junctions: Codable where J: Codable { + let items: [J] + + init(items: [J]) { + self.items = items + } + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + let type = try container.decode(String.self) + + if type == JunctionsConstants.hereField { + items = [] + } else if + type.count == 2, + type.starts(with: JunctionsConstants.junctionPrefix), + let itemsCount = Int(type.suffix(1)) { + if itemsCount == 1 { + let item = try container.decode(J.self) + items = [item] + } else { + let dict = try container.decode([String: J].self) + + items = try (0 ..< itemsCount).map { index in + guard let junction = dict[String(index)] else { + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: "Unsupported junctions: \(dict)" + ) + ) + } + + return junction + } + } + } else { + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: "Unsupported junctions format" + ) + ) + } + } func encode(to encoder: Encoder) throws { var container = encoder.unkeyedContainer() if items.isEmpty { - try container.encode("Here") + try container.encode(JunctionsConstants.hereField) } else { - let xLocation = "X\(items.count)" + let xLocation = "\(JunctionsConstants.junctionPrefix)\(items.count)" try container.encode(xLocation) } @@ -83,7 +236,7 @@ extension Xcm { } else if items.count == 1 { try container.encode(items[0]) } else { - var jsonDict: [String: Xcm.Junction] = [:] + var jsonDict: [String: J] = [:] for (index, item) in items.enumerated() { let key = String(index) jsonDict[key] = item @@ -93,18 +246,20 @@ extension Xcm { } } } + + typealias JunctionsV2 = Junctions } extension Xcm.Junctions { - func appending(components: [Xcm.Junction]) -> Xcm.Junctions { + func appending(components: [J]) -> Xcm.Junctions { Xcm.Junctions(items: items + components) } - func prepending(components: [Xcm.Junction]) -> Xcm.Junctions { + func prepending(components: [J]) -> Xcm.Junctions { Xcm.Junctions(items: components + items) } - func lastComponent() -> (Xcm.Junctions, Xcm.Junctions) { + func lastComponent() -> (Xcm.Junctions, Xcm.Junctions) { guard let lastJunction = items.last else { return (self, Xcm.Junctions(items: [])) } diff --git a/novawallet/Common/Substrate/Types/Xcm/XcmMultilocation.swift b/novawallet/Common/Substrate/Types/Xcm/XcmMultilocation.swift index 6041fd8291..476c359c07 100644 --- a/novawallet/Common/Substrate/Types/Xcm/XcmMultilocation.swift +++ b/novawallet/Common/Substrate/Types/Xcm/XcmMultilocation.swift @@ -5,6 +5,6 @@ import BigInt extension Xcm { struct Multilocation: Encodable { @StringCodable var parents: UInt8 - let interior: Xcm.Junctions + let interior: Xcm.JunctionsV2 } } diff --git a/novawallet/Common/Substrate/Types/Xcm/XcmNetworkId.swift b/novawallet/Common/Substrate/Types/Xcm/XcmNetworkId.swift deleted file mode 100644 index d0a96e1c40..0000000000 --- a/novawallet/Common/Substrate/Types/Xcm/XcmNetworkId.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import SubstrateSdk - -extension Xcm { - enum NetworkId: Encodable { - case any - case named(_ data: Data) - case polkadot - case kusama - - func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - - switch self { - case .any: - try container.encode("Any") - try container.encode(JSON.null) - case let .named(data): - try container.encode("Named") - try container.encode(BytesCodable(wrappedValue: data)) - case .polkadot: - try container.encode("Polkadot") - try container.encode(JSON.null) - case .kusama: - try container.encode("Kusama") - try container.encode(JSON.null) - } - } - } -} diff --git a/novawallet/Common/Validation/DataValidationProtocols.swift b/novawallet/Common/Validation/DataValidationProtocols.swift index 565d7a8949..cf696efbaa 100644 --- a/novawallet/Common/Validation/DataValidationProtocols.swift +++ b/novawallet/Common/Validation/DataValidationProtocols.swift @@ -1,6 +1,8 @@ import Foundation typealias DataValidationRunnerCompletion = () -> Void +typealias DataValidationRunnerStopClosure = (DataValidationProblem) -> Void +typealias DataValidationRunnerResumeClosure = (Int) -> Void enum DataValidationProblem { case warning @@ -18,5 +20,19 @@ protocol DataValidating { } protocol DataValidationRunnerProtocol { - func runValidation(notifyingOnSuccess closure: @escaping DataValidationRunnerCompletion) + func runValidation( + notifyingOnSuccess completionClosure: @escaping DataValidationRunnerCompletion, + notifyingOnStop stopClosure: DataValidationRunnerStopClosure?, + notifyingOnResume resumeClosure: DataValidationRunnerResumeClosure? + ) +} + +extension DataValidationRunnerProtocol { + func runValidation(notifyingOnSuccess closure: @escaping DataValidationRunnerCompletion) { + runValidation( + notifyingOnSuccess: closure, + notifyingOnStop: nil, + notifyingOnResume: nil + ) + } } diff --git a/novawallet/Common/Validation/DataValidationRunner.swift b/novawallet/Common/Validation/DataValidationRunner.swift index 24521c9e8c..f92aad11ce 100644 --- a/novawallet/Common/Validation/DataValidationRunner.swift +++ b/novawallet/Common/Validation/DataValidationRunner.swift @@ -4,7 +4,9 @@ final class DataValidationRunner { let validators: [DataValidating] private var lastIndex: Int = 0 - private var savedClosure: DataValidationRunnerCompletion? + private var completionClosure: DataValidationRunnerCompletion? + private var resumeClosure: DataValidationRunnerResumeClosure? + private var stopClosure: DataValidationRunnerStopClosure? init(validators: [DataValidating]) { self.validators = validators @@ -15,8 +17,12 @@ final class DataValidationRunner { } private func runValidation(from startIndex: Int) { + resumeClosure?(startIndex) + for index in startIndex ..< validators.count { if let problem = validators[index].validate(notifying: self) { + stopClosure?(problem) + switch problem { case .warning: lastIndex = index @@ -30,13 +36,20 @@ final class DataValidationRunner { } } - savedClosure?() + completionClosure?() } } extension DataValidationRunner: DataValidationRunnerProtocol { - func runValidation(notifyingOnSuccess closure: @escaping DataValidationRunnerCompletion) { - savedClosure = closure + func runValidation( + notifyingOnSuccess completionClosure: @escaping DataValidationRunnerCompletion, + notifyingOnStop stopClosure: DataValidationRunnerStopClosure?, + notifyingOnResume resumeClosure: DataValidationRunnerResumeClosure? + ) { + self.completionClosure = completionClosure + self.stopClosure = stopClosure + self.resumeClosure = resumeClosure + runValidation(from: 0) } } diff --git a/novawallet/Common/View/CollapsableContainerView.swift b/novawallet/Common/View/CollapsableContainerView.swift new file mode 100644 index 0000000000..d5df0c1892 --- /dev/null +++ b/novawallet/Common/View/CollapsableContainerView.swift @@ -0,0 +1,178 @@ +import SoraUI +import SnapKit + +protocol CollapsableContainerViewDelegate: AnyObject { + func animateAlongsideWithInfo(sender: AnyObject?) + func didChangeExpansion(isExpanded: Bool, sender: AnyObject) +} + +class CollapsableContainerView: UIView { + private enum Constants { + static let headerHeight: CGFloat = 32 + static let rowHeight: CGFloat = 44 + } + + let backgroundView = BlockBackgroundView() + + let networkInfoContainer: UIView = .create { + $0.backgroundColor = .clear + $0.clipsToBounds = true + } + + let contentView: UIView = .create { + $0.backgroundColor = .clear + } + + let titleControl: ActionTitleControl = .create { + $0.indicator = ResizableImageActionIndicator(size: .init(width: 24, height: 24)) + $0.imageView.image = R.image.iconLinkChevron()?.tinted(with: R.color.colorTextSecondary()!) + $0.identityIconAngle = CGFloat.pi / 2.0 + $0.activationIconAngle = -CGFloat.pi / 2.0 + $0.titleLabel.apply(style: .footnoteSecondary) + $0.titleLabel.textAlignment = .left + $0.titleLabel.numberOfLines = 1 + $0.layoutType = .flexible + $0.horizontalSpacing = 0 + $0.imageView.isUserInteractionEnabled = false + $0.activate(animated: false) + } + + let stackView: UIStackView = .create { + $0.axis = .vertical + $0.distribution = .fill + $0.alignment = .fill + $0.spacing = 0.0 + $0.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + $0.isLayoutMarginsRelativeArrangement = true + } + + var contentInsets: UIEdgeInsets = .zero { + didSet { + stackView.layoutMargins = contentInsets + } + } + + weak var delegate: CollapsableContainerViewDelegate? + + lazy var expansionAnimator: BlockViewAnimatorProtocol = BlockViewAnimator() + + var expanded: Bool { titleControl.isActivated } + + override init(frame: CGRect) { + super.init(frame: frame) + + setupLayout() + setupHandlers() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setExpanded(_ value: Bool, animated: Bool) { + if value { + titleControl.activate(animated: animated) + } else { + titleControl.deactivate(animated: animated) + } + + applyExpansion(animated: animated, shouldNotifyDelegate: false) + } + + private func setupHandlers() { + titleControl.addTarget(self, action: #selector(actionToggleExpansion), for: .valueChanged) + } + + var rows: [UIView] { + [] + } + + private func setupLayout() { + addSubview(titleControl) + titleControl.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(Constants.headerHeight) + } + + addSubview(networkInfoContainer) + networkInfoContainer.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.top.equalTo(titleControl.snp.bottom) + } + + networkInfoContainer.addSubview(contentView) + contentView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + contentView.addSubview(backgroundView) + + backgroundView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + contentView.addSubview(stackView) + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + rows.forEach { view in + stackView.addArrangedSubview(view) + + view.snp.makeConstraints { make in + make.height.equalTo(Constants.rowHeight) + } + } + } + + private func applyExpansion(animated: Bool, shouldNotifyDelegate: Bool) { + if animated { + expansionAnimator.animate(block: { [weak self] in + guard let self = self else { + return + } + + self.applyExpansionState(shouldNotifyDelegate) + + let animation = CABasicAnimation() + animation.toValue = self.backgroundView.contentView?.shapePath + self.backgroundView.contentView?.layer + .add(animation, forKey: #keyPath(CAShapeLayer.path)) + + self.delegate?.animateAlongsideWithInfo(sender: self) + }, completionBlock: nil) + } else { + applyExpansionState(shouldNotifyDelegate) + setNeedsLayout() + } + } + + private func applyExpansionState(_ shouldNotifyDelegate: Bool) { + if expanded { + contentView.snp.updateConstraints { make in + make.top.equalToSuperview() + } + layoutIfNeeded() + + if shouldNotifyDelegate { + delegate?.didChangeExpansion(isExpanded: true, sender: self) + } + } else { + contentView.snp.updateConstraints { make in + make.top.equalToSuperview().offset( + -CGFloat(stackView.arrangedSubviews.count) * Constants.rowHeight + ) + } + layoutIfNeeded() + + if shouldNotifyDelegate { + delegate?.didChangeExpansion(isExpanded: false, sender: self) + } + } + } + + @objc func actionToggleExpansion() { + applyExpansion(animated: true, shouldNotifyDelegate: true) + } +} diff --git a/novawallet/Common/View/HintView.swift b/novawallet/Common/View/HintView.swift index 9b1b8d9d31..4ab392422c 100644 --- a/novawallet/Common/View/HintView.swift +++ b/novawallet/Common/View/HintView.swift @@ -10,7 +10,7 @@ final class HintView: UIView { }() let iconView: UIImageView = { - let view = UIImageView(image: R.image.iconInfoFilled()?.withRenderingMode(.alwaysTemplate)) + let view = UIImageView(image: R.image.iconInfoFilled()) view.tintColor = R.color.colorIconInactive() return view }() diff --git a/novawallet/Common/View/InlineAlertView.swift b/novawallet/Common/View/InlineAlertView.swift index c2638f8c04..31397767c6 100644 --- a/novawallet/Common/View/InlineAlertView.swift +++ b/novawallet/Common/View/InlineAlertView.swift @@ -63,4 +63,31 @@ extension InlineAlertView { view.contentView.stackView.alignment = .top return view } + + static func info() -> InlineAlertView { + let view = InlineAlertView() + view.backgroundView.fillColor = R.color.colorIndividualChipBackground()! + view.contentView.imageView.image = R.image.iconInfoAccent()! + view.contentView.stackView.alignment = .top + return view + } + + static func inline(for style: InlineAlertView.Style) -> InlineAlertView { + switch style { + case .error: + return InlineAlertView.error() + case .warning: + return InlineAlertView.warning() + case .info: + return InlineAlertView.info() + } + } +} + +extension InlineAlertView { + enum Style { + case error + case warning + case info + } } diff --git a/novawallet/Common/View/LinkView.swift b/novawallet/Common/View/LinkView.swift index d071ef91b3..56ba830b9f 100644 --- a/novawallet/Common/View/LinkView.swift +++ b/novawallet/Common/View/LinkView.swift @@ -18,7 +18,7 @@ final class LinkView: IconDetailsGenericView { let blueColor = R.color.colorButtonTextAccent()! mode = .iconDetails - imageView.image = R.image.iconInfoFilled()?.tinted(with: blueColor) + imageView.image = R.image.iconInfoAccent() spacing = 5.0 actionButton.applyIconStyle() diff --git a/novawallet/Common/View/ScrollableContainerView/ScrollableContainerLayoutView.swift b/novawallet/Common/View/ScrollableContainerView/ScrollableContainerLayoutView.swift index b77cb3a26b..48d135b540 100644 --- a/novawallet/Common/View/ScrollableContainerView/ScrollableContainerLayoutView.swift +++ b/novawallet/Common/View/ScrollableContainerView/ScrollableContainerLayoutView.swift @@ -58,15 +58,16 @@ class ScrollableContainerLayoutView: UIView { } } - func applyWarning( - on warningView: inout InlineAlertView?, + func applyInline( + on inlineView: inout InlineAlertView?, + style: InlineAlertView.Style, after view: UIView?, text: String?, spacing: CGFloat = 0 ) { if let text = text { - if warningView == nil { - let newView = InlineAlertView.warning() + if inlineView == nil { + let newView = InlineAlertView.inline(for: style) if let afterView = view { insertArrangedSubview(newView, after: afterView, spacingAfter: spacing) @@ -74,17 +75,47 @@ class ScrollableContainerLayoutView: UIView { addArrangedSubview(newView, spacingAfter: spacing) } - warningView = newView + inlineView = newView } - warningView?.contentView.detailsLabel.text = text + inlineView?.contentView.detailsLabel.text = text } else { - warningView?.removeFromSuperview() - warningView = nil + inlineView?.removeFromSuperview() + inlineView = nil } setNeedsLayout() } + + func applyWarning( + on warningView: inout InlineAlertView?, + after view: UIView?, + text: String?, + spacing: CGFloat = 0 + ) { + applyInline( + on: &warningView, + style: .warning, + after: view, + text: text, + spacing: spacing + ) + } + + func applyInfo( + on infoView: inout InlineAlertView?, + after view: UIView?, + text: String?, + spacing: CGFloat = 0 + ) { + applyInline( + on: &infoView, + style: .info, + after: view, + text: text, + spacing: spacing + ) + } } class SCGenericActionLayoutView: ScrollableContainerLayoutView { diff --git a/novawallet/Common/View/StackTable/StackAddressCell.swift b/novawallet/Common/View/StackTable/StackAddressCell.swift index 73fdf25be8..7c267e472e 100644 --- a/novawallet/Common/View/StackTable/StackAddressCell.swift +++ b/novawallet/Common/View/StackTable/StackAddressCell.swift @@ -56,9 +56,7 @@ final class StackAddressCell: RowView> { +final class StackTitleMultiValueCell: RowView>, SkeletonableView { var titleLabel: UILabel { rowContentView.titleView.detailsLabel } var topValueLabel: UILabel { rowContentView.valueView.valueTop } var bottomValueLabel: UILabel { rowContentView.valueView.valueBottom } + var skeletonView: SkrullableView? + + private var isLoading: Bool = false + + override func layoutSubviews() { + super.layoutSubviews() + + if isLoading { + updateLoadingState() + skeletonView?.restartSkrulling() + } + } convenience init() { self.init(frame: CGRect(origin: .zero, size: CGSize(width: 340, height: 44.0))) @@ -34,9 +47,7 @@ final class StackTitleMultiValueCell: RowView) { + switch loadableViewModel { + case let .cached(value), let .loaded(value): + isLoading = false + rowContentView.valueView.valueTop.text = value + invalidateLayout() + case .loading: + isLoading = true + invalidateLayout() + } + } +} + +extension StackTitleMultiValueCell { + func createSkeletons(for spaceSize: CGSize) -> [Skeletonable] { + let size = CGSize(width: 68, height: 8) + let offset = CGPoint( + x: spaceSize.width - size.width, + y: spaceSize.height / 2.0 - size.height / 2.0 + ) + + let row = SingleSkeleton.createRow( + on: self, + containerView: self, + spaceSize: spaceSize, + offset: offset, + size: size + ) + + return [row] + } + + var skeletonSuperview: UIView { + self + } + + var hidingViews: [UIView] { + [rowContentView.valueView] + } + + func didStartSkeleton() { + isLoading = true + } + + func didStopSkeleton() { + isLoading = false + } } diff --git a/novawallet/Common/View/TextInputView/PercentInputView.swift b/novawallet/Common/View/TextInputView/PercentInputView.swift new file mode 100644 index 0000000000..667dd93b02 --- /dev/null +++ b/novawallet/Common/View/TextInputView/PercentInputView.swift @@ -0,0 +1,251 @@ +import SnapKit +import SoraUI + +protocol PercentInputViewDelegateProtocol: AnyObject { + func didSelect(percent: SlippagePercentViewModel, sender: Any?) +} + +final class PercentInputView: BackgroundedContentControl { + let textField: UITextField = .create { + $0.font = UIFont.regularSubheadline + $0.textColor = R.color.colorTextPrimary() + $0.tintColor = R.color.colorTextPrimary() + $0.textAlignment = .left + + $0.attributedPlaceholder = NSAttributedString( + string: "0.5 %", + attributes: [ + .foregroundColor: R.color.colorHintText()!, + .font: UIFont.regularSubheadline + ] + ) + + $0.keyboardType = .decimalPad + $0.clearButtonMode = .always + } + + let symbolLabel: UILabel = .create { + $0.apply(style: .regularSubhedlinePrimary) + $0.numberOfLines = 1 + $0.textAlignment = .left + $0.text = "%" + $0.isHidden = true + } + + var roundedBackgroundView: RoundedView? { + backgroundView as? RoundedView + } + + var buttonsStack = UIView.hStack( + alignment: .center, + distribution: .equalSpacing, + spacing: 8, + [] + ) + + weak var delegate: PercentInputViewDelegateProtocol? + private(set) var inputViewModel: AmountInputViewModelProtocol? + private var viewModel: [SlippagePercentViewModel] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + + contentInsets = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + configureBackgroundViewIfNeeded() + configureContentView() + setupTextFieldHandlers() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + let availableWidth = bounds.width - contentInsets.left - contentInsets.right + let textFieldWidth = max(availableWidth, 0) + + let textFieldHeight: CGFloat = textField.intrinsicContentSize.height + + textField.frame = CGRect( + x: bounds.maxX - contentInsets.right - textFieldWidth, + y: bounds.midY - textFieldHeight / 2, + width: textFieldWidth, + height: textFieldHeight + ) + + if !symbolLabel.isHidden { + let text = textField.text ?? "" + let texFieldContentSize = text.size(withAttributes: textField.typingAttributes) + symbolLabel.frame = .init( + x: textField.frame.minX + texFieldContentSize.width + 4, + y: textField.frame.midY - symbolLabel.intrinsicContentSize.height / 2, + width: symbolLabel.intrinsicContentSize.width, + height: symbolLabel.intrinsicContentSize.height + ) + } else { + symbolLabel.frame = .zero + } + + if !buttonsStack.isHidden, !buttonsStack.arrangedSubviews.isEmpty { + var buttonsWidth: CGFloat = buttonsStack.arrangedSubviews.reduce(into: 0) { + $0 = $0 + $1.intrinsicContentSize.width + } + buttonsWidth += CGFloat(buttonsStack.arrangedSubviews.count - 1) * 8 + let height: CGFloat = buttonsStack.arrangedSubviews.max(by: { + $0.intrinsicContentSize.height > $1.intrinsicContentSize.height + })?.intrinsicContentSize.height ?? 0 + let buttonStackX = bounds.maxX - contentInsets.right - buttonsWidth + + buttonsStack.frame = .init( + x: buttonStackX, + y: textField.frame.midY - height / 2, + width: buttonsWidth, + height: height + ) + } else { + buttonsStack.frame = .zero + } + + backgroundView?.frame = bounds + } + + private func configureBackgroundViewIfNeeded() { + if backgroundView == nil { + let roundedView = RoundedView() + roundedView.apply(style: .strokeOnEditing) + roundedView.isUserInteractionEnabled = false + backgroundView = roundedView + } + } + + private func configureContentView() { + addSubview(textField) + addSubview(symbolLabel) + addSubview(buttonsStack) + } + + private func setupTextFieldHandlers() { + textField.addTarget(self, action: #selector(editingDidBeginAction), for: .editingDidBegin) + textField.addTarget(self, action: #selector(editingDidEndAction), for: .editingDidEnd) + textField.addTarget(self, action: #selector(editingDidEndAction), for: .editingDidEndOnExit) + textField.delegate = self + } + + @objc private func editingDidBeginAction() { + buttonsStack.isHidden = true + roundedBackgroundView?.strokeWidth = textField.isFirstResponder ? 0.5 : 0.0 + } + + @objc private func editingDidEndAction() { + if textField.text.isNilOrEmpty { + buttonsStack.isHidden = false + } + roundedBackgroundView?.strokeWidth = textField.isFirstResponder ? 0.5 : 0.0 + } + + @objc private func buttonAction(_ sender: RoundedButton) { + guard let delegate = delegate else { + return + } + if let index = buttonsStack.arrangedSubviews.firstIndex(where: { $0 === sender }), + let buttonModel = viewModel[safe: index] { + delegate.didSelect(percent: buttonModel, sender: self) + } + } + + private func createButton(title: String) -> RoundedButton { + let button = RoundedButton() + button.applyAccessoryStyle() + button.contentInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) + button.imageWithTitleView?.title = title + button.addTarget(self, action: #selector(buttonAction), for: .touchUpInside) + return button + } + + private func updateViewsVisibility(for text: String?) { + symbolLabel.isHidden = text.isNilOrEmpty + buttonsStack.isHidden = !text.isNilOrEmpty + textField.clearButtonMode = text.isNilOrEmpty ? .never : .always + setNeedsLayout() + layoutIfNeeded() + } +} + +extension PercentInputView: UITextFieldDelegate { + func textField( + _: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + let shouldChangeCharacters = inputViewModel?.didReceiveReplacement(string, for: range) ?? false + updateViewsVisibility(for: inputViewModel?.displayAmount) + return shouldChangeCharacters + } + + func textFieldShouldClear(_ textField: UITextField) -> Bool { + updateViewsVisibility(for: "") + if let text = textField.text { + inputViewModel?.didReceiveReplacement("", for: NSRange(location: 0, length: text.count)) + textField.text = "" + return false + } + + return true + } +} + +extension PercentInputView: AmountInputViewModelObserver { + func amountInputDidChange() { + textField.text = inputViewModel?.displayAmount + updateViewsVisibility(for: textField.text) + + sendActions(for: .editingChanged) + } +} + +extension PercentInputView { + func bind(viewModel: [SlippagePercentViewModel]) { + buttonsStack.arrangedSubviews.forEach { + $0.removeFromSuperview() + } + viewModel.forEach { + buttonsStack.addArrangedSubview( + createButton(title: $0.title) + ) + } + self.viewModel = viewModel + } + + func bind(inputViewModel: AmountInputViewModelProtocol) { + self.inputViewModel?.observable.remove(observer: self) + inputViewModel.observable.add(observer: self) + + self.inputViewModel = inputViewModel + textField.text = inputViewModel.displayAmount + updateViewsVisibility(for: textField.text) + } +} + +extension PercentInputView { + enum Style { + case error + case normal + } + + func apply(style: Style) { + switch style { + case .error: + let color = R.color.colorTextNegative()! + roundedBackgroundView?.strokeWidth = 0.5 + roundedBackgroundView?.strokeColor = color + textField.textColor = color + symbolLabel.textColor = color + case .normal: + roundedBackgroundView?.strokeWidth = textField.isFirstResponder ? 0.5 : 0.0 + roundedBackgroundView?.strokeColor = R.color.colorActiveBorder()! + textField.textColor = R.color.colorTextPrimary() + symbolLabel.textColor = R.color.colorTextPrimary() + } + } +} diff --git a/novawallet/Common/View/UIFactory.swift b/novawallet/Common/View/UIFactory.swift index f611448937..e437447b6d 100644 --- a/novawallet/Common/View/UIFactory.swift +++ b/novawallet/Common/View/UIFactory.swift @@ -332,24 +332,24 @@ final class UIFactory: UIFactoryProtocol { private func createActionsAccessoryView( for toolBar: UIToolbar, + style: ToolBarStyle = .defaultSyle, actions: [ViewSelectorAction], doneAction: ViewSelectorAction, target: Any?, spacing: CGFloat ) -> UIToolbar { - toolBar.isTranslucent = false - - let background = UIImage.background(from: .clear) - toolBar.setBackgroundImage( - background, - forToolbarPosition: .any, - barMetrics: .default - ) + toolBar.isTranslucent = style.isTranslucent + + if let backgroundColor = style.backgroundColor { + let background = UIImage.background(from: backgroundColor) + toolBar.setBackgroundImage( + background, + forToolbarPosition: .any, + barMetrics: .default + ) + } - let actionAttributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: R.color.colorTextPrimary()!, - .font: UIFont.p1Paragraph - ] + let actionAttributes = style.actionAttributes let barItems = actions.reduce([UIBarButtonItem]()) { result, action in let barItem = UIBarButtonItem( @@ -388,10 +388,7 @@ final class UIFactory: UIFactoryProtocol { action: doneAction.selector ) - let doneAttributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: R.color.colorTextPrimary()!, - .font: UIFont.h5Title - ] + let doneAttributes = style.doneAttributes doneItem.setTitleTextAttributes(doneAttributes, for: .normal) doneItem.setTitleTextAttributes(doneAttributes, for: .highlighted) @@ -548,3 +545,55 @@ final class UIFactory: UIFactoryProtocol { return view } } + +extension UIFactory { + func createDoneAccessoryView( + target: Any?, + selector: Selector, + locale: Locale + ) -> UIToolbar { + let frame = CGRect( + x: 0.0, + y: 0.0, + width: UIScreen.main.bounds.width, + height: UIConstants.accessoryBarHeight + ) + + let toolBar = UIToolbar(frame: frame) + + let doneTitle = R.string.localizable.commonDone(preferredLanguages: locale.rLanguages) + let doneAction = ViewSelectorAction( + title: doneTitle, + selector: selector + ) + return createActionsAccessoryView( + for: toolBar, + actions: [], + doneAction: doneAction, + target: target, + spacing: 0 + ) + } +} + +extension UIFactory { + struct ToolBarStyle { + let backgroundColor: UIColor? + let isTranslucent: Bool + let doneAttributes: [NSAttributedString.Key: Any] + let actionAttributes: [NSAttributedString.Key: Any] + + static var defaultSyle = ToolBarStyle( + backgroundColor: .clear, + isTranslucent: false, + doneAttributes: [ + .foregroundColor: R.color.colorTextPrimary()!, + .font: UIFont.h5Title + ], + actionAttributes: [ + .foregroundColor: R.color.colorTextPrimary()!, + .font: UIFont.p1Paragraph + ] + ) + } +} diff --git a/novawallet/Common/ViewController/ModalPicker/Cell/TokenOperationTableViewCell.swift b/novawallet/Common/ViewController/ModalPicker/Cell/TokenOperationTableViewCell.swift new file mode 100644 index 0000000000..a9e0283fdc --- /dev/null +++ b/novawallet/Common/ViewController/ModalPicker/Cell/TokenOperationTableViewCell.swift @@ -0,0 +1,88 @@ +import UIKit + +final class TokenOperationTableViewCell: UITableViewCell, ModalPickerCellProtocol { + struct Model { + let content: IconWithTitleSubtitleViewModel + let isActive: Bool + } + + private(set) var titleLabel = UILabel() + private(set) var subtitleLabel = UILabel() + private(set) var iconImageView = UIImageView() + let arrowIcon = R.image.iconSmallArrow()?.tinted(with: R.color.colorIconSecondary()!) + + var checkmarked: Bool = false + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupStyle() + setupLayout() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func bind(model: Model) { + titleLabel.text = model.content.title + subtitleLabel.text = model.content.subtitle + iconImageView.image = model.content.icon?.withRenderingMode(.alwaysTemplate) + + if model.isActive { + titleLabel.textColor = R.color.colorTextPrimary() + subtitleLabel.textColor = R.color.colorTextSecondary() + iconImageView.tintColor = R.color.colorIconPrimary() + accessoryView = UIImageView(image: arrowIcon) + selectionStyle = .default + } else { + titleLabel.textColor = R.color.colorButtonTextInactive() + subtitleLabel.textColor = R.color.colorButtonTextInactive() + iconImageView.tintColor = R.color.colorIconInactive() + accessoryView = nil + selectionStyle = .none + } + } + + private func setupStyle() { + backgroundColor = .clear + + selectedBackgroundView = UIView() + selectedBackgroundView?.backgroundColor = R.color.colorCellBackgroundPressed() + + separatorInset = UIEdgeInsets( + top: 0, + left: UIConstants.horizontalInset, + bottom: 0, + right: UIConstants.horizontalInset + ) + + titleLabel.apply(style: .regularSubhedlinePrimary) + subtitleLabel.apply(style: .footnoteSecondary) + } + + private func setupLayout() { + contentView.addSubview(iconImageView) + iconImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(UIConstants.horizontalInset) + make.centerY.equalToSuperview() + } + + iconImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + iconImageView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + contentView.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(7) + make.leading.equalTo(iconImageView.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(8) + } + + contentView.addSubview(subtitleLabel) + subtitleLabel.snp.makeConstraints { make in + make.bottom.equalToSuperview().inset(7) + make.leading.equalTo(iconImageView.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(8) + } + } +} diff --git a/novawallet/Common/ViewController/ModalPicker/ModalNetworksFactory.swift b/novawallet/Common/ViewController/ModalPicker/ModalNetworksFactory.swift index 895c13d865..2c57de252a 100644 --- a/novawallet/Common/ViewController/ModalPicker/ModalNetworksFactory.swift +++ b/novawallet/Common/ViewController/ModalPicker/ModalNetworksFactory.swift @@ -31,6 +31,7 @@ enum ModalNetworksFactory { viewController.footerHeight = 0.0 viewController.sectionHeaderHeight = 32 viewController.sectionFooterHeight = 32 + viewController.isScrollEnabled = true return viewController } @@ -54,7 +55,7 @@ enum ModalNetworksFactory { let networkViewModelFactory = NetworkViewModelFactory() let onChainViewModel = LocalizableResource { _ in - networkViewModelFactory.createViewModel(from: selectionState.originChain) + networkViewModelFactory.createViewModel(from: selectionState.chain) } let onChainTitle = LocalizableResource { locale in @@ -63,7 +64,7 @@ enum ModalNetworksFactory { viewController.addSection(viewModels: [onChainViewModel], title: onChainTitle) - let crossChainViewModels = selectionState.availableDestChains.map { chain in + let crossChainViewModels = selectionState.availablePeerChains.map { chain in LocalizableResource { _ in networkViewModelFactory.createViewModel(from: chain) } } @@ -73,10 +74,10 @@ enum ModalNetworksFactory { viewController.addSection(viewModels: crossChainViewModels, title: crossChainTitle) - if selectionState.selectedChainId == selectionState.originChain.chainId { + if selectionState.selectedChainId == selectionState.chain.chainId { viewController.selectedIndex = 0 viewController.selectedSection = 0 - } else if let index = selectionState.availableDestChains.firstIndex( + } else if let index = selectionState.availablePeerChains.firstIndex( where: { selectionState.selectedChainId == $0.chainId } ) { viewController.selectedIndex = index diff --git a/novawallet/Common/ViewController/ModalPicker/ModalPickerFactory.swift b/novawallet/Common/ViewController/ModalPicker/ModalPickerFactory.swift index f63b42f454..cafa73d18f 100644 --- a/novawallet/Common/ViewController/ModalPicker/ModalPickerFactory.swift +++ b/novawallet/Common/ViewController/ModalPicker/ModalPickerFactory.swift @@ -508,3 +508,59 @@ extension ModalPickerFactory { return viewController } } + +extension ModalPickerFactory { + static func createPickerListForOperations( + operations: [DepositOperationModel], + delegate: ModalPickerViewControllerDelegate?, + token: String, + context: AnyObject? + ) -> UIViewController? { + guard !operations.isEmpty else { + return nil + } + + let viewController: ModalPickerViewController + = ModalPickerViewController(nib: R.nib.modalPickerViewController) + + viewController.localizedTitle = .init { + R.string.localizable.swapsSetupDepositTitle( + token, + preferredLanguages: $0.rLanguages + ) + } + + viewController.selectedIndex = NSNotFound + viewController.delegate = delegate + viewController.modalPresentationStyle = .custom + viewController.context = context + viewController.headerBorderType = .none + viewController.separatorStyle = .none + viewController.separatorColor = R.color.colorDivider() + viewController.cellHeight = 48 + + viewController.viewModels = operations.map { operation in + LocalizableResource { locale in + TokenOperationTableViewCell.Model( + content: .init( + title: operation.titleForLocale(locale), + subtitle: operation.subtitleForLocale(locale, token: token), + icon: operation.icon + ), + isActive: operation.active + ) + } + } + + let factory = ModalSheetPresentationFactory(configuration: ModalSheetPresentationConfiguration.nova) + viewController.modalTransitioningFactory = factory + + let height = viewController.headerHeight + CGFloat(operations.count) * viewController.cellHeight + + viewController.footerHeight + viewController.preferredContentSize = CGSize(width: 0.0, height: height) + + viewController.localizationManager = LocalizationManager.shared + + return viewController + } +} diff --git a/novawallet/Common/ViewController/ModalPicker/ModalPickerViewController.swift b/novawallet/Common/ViewController/ModalPicker/ModalPickerViewController.swift index 734af0bee5..815f23d255 100644 --- a/novawallet/Common/ViewController/ModalPicker/ModalPickerViewController.swift +++ b/novawallet/Common/ViewController/ModalPicker/ModalPickerViewController.swift @@ -106,6 +106,10 @@ class ModalPickerViewController sectionFooters[lastSectionIndex] = footer } + func reload() { + tableView.reloadData() + } + private func configure() { if let cellNib = cellNib { tableView.register(cellNib, forCellReuseIdentifier: cellIdentifier) diff --git a/novawallet/Common/ViewModel/BalanceViewModelFactoryFacade+Formatting.swift b/novawallet/Common/ViewModel/BalanceViewModelFactoryFacade+Formatting.swift new file mode 100644 index 0000000000..d3278b107b --- /dev/null +++ b/novawallet/Common/ViewModel/BalanceViewModelFactoryFacade+Formatting.swift @@ -0,0 +1,19 @@ +import Foundation +import SoraFoundation + +extension BalanceViewModelFactoryFacadeProtocol { + func rateFromValue( + mainSymbol: String, + targetAssetInfo: AssetBalanceDisplayInfo, + value: Decimal + ) -> LocalizableResource { + let targetString = amountFromValue( + targetAssetInfo: targetAssetInfo, + value: value + ) + + return LocalizableResource { locale in + "1 \(mainSymbol)".estimatedEqual(to: targetString.value(for: locale)) + } + } +} diff --git a/novawallet/Modules/Vote/Crowdloan/CrowdloanList/BalanceViewModelFactoryFacade.swift b/novawallet/Common/ViewModel/BalanceViewModelFactoryFacade.swift similarity index 100% rename from novawallet/Modules/Vote/Crowdloan/CrowdloanList/BalanceViewModelFactoryFacade.swift rename to novawallet/Common/ViewModel/BalanceViewModelFactoryFacade.swift diff --git a/novawallet/Modules/AssetConversion/Model/AssetConversion.swift b/novawallet/Modules/AssetConversion/Model/AssetConversion.swift new file mode 100644 index 0000000000..0c1998cf5c --- /dev/null +++ b/novawallet/Modules/AssetConversion/Model/AssetConversion.swift @@ -0,0 +1,64 @@ +import Foundation +import BigInt + +enum AssetConversion { + enum Direction { + case sell + case buy + } + + struct QuoteArgs: Equatable { + let assetIn: ChainAssetId + let assetOut: ChainAssetId + let amount: BigUInt + let direction: Direction + } + + struct Quote: Equatable { + let amountIn: BigUInt + let assetIn: ChainAssetId + let amountOut: BigUInt + let assetOut: ChainAssetId + + init(args: QuoteArgs, amount: BigUInt) { + switch args.direction { + case .sell: + amountIn = args.amount + amountOut = amount + case .buy: + amountIn = amount + amountOut = args.amount + } + + assetIn = args.assetIn + assetOut = args.assetOut + } + + func matches(other quote: Quote, slippage: BigRational, direction: Direction) -> Bool { + switch direction { + case .sell: + let amountOutMin = amountOut - slippage.mul(value: amountOut) + + return amountOutMin <= quote.amountOut + case .buy: + let amountInMax = amountIn + slippage.mul(value: amountIn) + + return amountInMax >= quote.amountIn + } + } + } + + struct CallArgs: Hashable { + let assetIn: ChainAssetId + let amountIn: BigUInt + let assetOut: ChainAssetId + let amountOut: BigUInt + let receiver: AccountId + let direction: Direction + let slippage: BigRational + } +} + +extension AssetConversion.CallArgs { + var identifier: String { "\(hashValue)" } +} diff --git a/novawallet/Modules/AssetConversion/Service/AssetConversionAggregationFactory.swift b/novawallet/Modules/AssetConversion/Service/AssetConversionAggregationFactory.swift new file mode 100644 index 0000000000..2e0a7c3ac7 --- /dev/null +++ b/novawallet/Modules/AssetConversion/Service/AssetConversionAggregationFactory.swift @@ -0,0 +1,129 @@ +import Foundation +import RobinHood + +protocol AssetConversionAggregationFactoryProtocol { + func createAvailableDirectionsWrapper( + for chainAsset: ChainAsset + ) -> CompoundOperationWrapper> + + func createAvailableDirectionsWrapper( + for chain: ChainModel + ) -> CompoundOperationWrapper<[ChainAssetId: Set]> + + func createQuoteWrapper( + for chain: ChainModel, + args: AssetConversion.QuoteArgs + ) -> CompoundOperationWrapper +} + +enum AssetConversionAggregationFactoryError: Error { + case unavailableProvider(ChainModel) +} + +final class AssetConversionAggregationFactory { + let operationQueue: OperationQueue + let chainRegistry: ChainRegistryProtocol + + init( + chainRegistry: ChainRegistryProtocol, + operationQueue: OperationQueue + ) { + self.chainRegistry = chainRegistry + self.operationQueue = operationQueue + } + + private func createAssetHubAllDirections( + for chain: ChainModel + ) -> CompoundOperationWrapper<[ChainAssetId: Set]> { + guard let connection = chainRegistry.getConnection(for: chain.chainId) else { + return .createWithError(ChainRegistryError.connectionUnavailable) + } + + guard let runtimeService = chainRegistry.getRuntimeProvider(for: chain.chainId) else { + return .createWithError(ChainRegistryError.runtimeMetadaUnavailable) + } + + return AssetHubSwapOperationFactory( + chain: chain, + runtimeService: runtimeService, + connection: connection, + operationQueue: operationQueue + ).availableDirections() + } + + private func createAssetHubDirections(for chainAsset: ChainAsset) -> CompoundOperationWrapper> { + guard let connection = chainRegistry.getConnection(for: chainAsset.chain.chainId) else { + return .createWithError(ChainRegistryError.connectionUnavailable) + } + + guard let runtimeService = chainRegistry.getRuntimeProvider(for: chainAsset.chain.chainId) else { + return .createWithError(ChainRegistryError.runtimeMetadaUnavailable) + } + + return AssetHubSwapOperationFactory( + chain: chainAsset.chain, + runtimeService: runtimeService, + connection: connection, + operationQueue: operationQueue + ).availableDirectionsForAsset(chainAsset.chainAssetId) + } + + private func createAssetHubQuote( + for chain: ChainModel, + args: AssetConversion.QuoteArgs + ) -> CompoundOperationWrapper { + guard let connection = chainRegistry.getConnection(for: chain.chainId) else { + return .createWithError(ChainRegistryError.connectionUnavailable) + } + + guard let runtimeService = chainRegistry.getRuntimeProvider(for: chain.chainId) else { + return .createWithError(ChainRegistryError.runtimeMetadaUnavailable) + } + + return AssetHubSwapOperationFactory( + chain: chain, + runtimeService: runtimeService, + connection: connection, + operationQueue: operationQueue + ).quote(for: args) + } +} + +extension AssetConversionAggregationFactory: AssetConversionAggregationFactoryProtocol { + func createAvailableDirectionsWrapper( + for chainAsset: ChainAsset + ) -> CompoundOperationWrapper> { + if chainAsset.chain.hasSwapHub { + return createAssetHubDirections(for: chainAsset) + } else { + return CompoundOperationWrapper.createWithError( + AssetConversionAggregationFactoryError.unavailableProvider(chainAsset.chain) + ) + } + } + + func createAvailableDirectionsWrapper( + for chain: ChainModel + ) -> CompoundOperationWrapper<[ChainAssetId: Set]> { + if chain.hasSwapHub { + return createAssetHubAllDirections(for: chain) + } else { + return CompoundOperationWrapper.createWithError( + AssetConversionAggregationFactoryError.unavailableProvider(chain) + ) + } + } + + func createQuoteWrapper( + for chain: ChainModel, + args: AssetConversion.QuoteArgs + ) -> CompoundOperationWrapper { + if chain.hasSwapHub { + return createAssetHubQuote(for: chain, args: args) + } else { + return CompoundOperationWrapper.createWithError( + AssetConversionAggregationFactoryError.unavailableProvider(chain) + ) + } + } +} diff --git a/novawallet/Modules/AssetConversion/Service/AssetConversionExtrinsicService.swift b/novawallet/Modules/AssetConversion/Service/AssetConversionExtrinsicService.swift new file mode 100644 index 0000000000..e1f0846c42 --- /dev/null +++ b/novawallet/Modules/AssetConversion/Service/AssetConversionExtrinsicService.swift @@ -0,0 +1,13 @@ +import Foundation +import RobinHood + +protocol AssetConversionExtrinsicServiceProtocol { + func fetchExtrinsicBuilderClosure( + for args: AssetConversion.CallArgs, + codingFactory: RuntimeCoderFactoryProtocol + ) -> ExtrinsicBuilderClosure +} + +enum AssetConversionExtrinsicServiceError: Error { + case remoteAssetNotFound(ChainAssetId) +} diff --git a/novawallet/Modules/AssetConversion/Service/AssetConversionFeeService.swift b/novawallet/Modules/AssetConversion/Service/AssetConversionFeeService.swift new file mode 100644 index 0000000000..f827230900 --- /dev/null +++ b/novawallet/Modules/AssetConversion/Service/AssetConversionFeeService.swift @@ -0,0 +1,51 @@ +import Foundation +import BigInt + +extension AssetConversion { + struct AmountWithNative: Equatable { + let targetAmount: BigUInt + let nativeAmount: BigUInt + } + + struct FeeModel: Equatable { + let totalFee: AmountWithNative + let networkFee: AmountWithNative + + var networkNativeFeeAddition: AmountWithNative? { + let targetAmount = totalFee.targetAmount > networkFee.targetAmount ? + totalFee.targetAmount - networkFee.targetAmount : 0 + + guard targetAmount > 0 else { + return nil + } + + let nativeAmount = totalFee.nativeAmount > networkFee.nativeAmount ? + totalFee.nativeAmount - networkFee.nativeAmount : 0 + + return .init(targetAmount: targetAmount, nativeAmount: nativeAmount) + } + } + + typealias FeeResult = Result +} + +typealias AssetConversionFeeServiceClosure = (AssetConversion.FeeResult) -> Void + +protocol AssetConversionFeeServiceProtocol { + func calculate( + in asset: ChainAsset, + callArgs: AssetConversion.CallArgs, + runCompletionIn queue: DispatchQueue, + completion closure: @escaping AssetConversionFeeServiceClosure + ) +} + +enum AssetConversionFeeServiceError: Error { + case accountMissing + case chainRuntimeMissing + case chainConnectionMissing + case utilityAssetMissing + case feeAssetConversionFailed + case setupFailed(String) + case calculationFailed(String) +} diff --git a/novawallet/Modules/AssetConversion/Service/AssetConversionOperationFactory.swift b/novawallet/Modules/AssetConversion/Service/AssetConversionOperationFactory.swift new file mode 100644 index 0000000000..dbff0fddb9 --- /dev/null +++ b/novawallet/Modules/AssetConversion/Service/AssetConversionOperationFactory.swift @@ -0,0 +1,14 @@ +import Foundation +import RobinHood + +protocol AssetConversionOperationFactoryProtocol { + func availableDirections() -> CompoundOperationWrapper<[ChainAssetId: Set]> + func availableDirectionsForAsset(_ chainAssetId: ChainAssetId) -> CompoundOperationWrapper> + func quote(for args: AssetConversion.QuoteArgs) -> CompoundOperationWrapper +} + +enum AssetConversionOperationError: Error { + case remoteAssetNotFound(ChainAssetId) + case runtimeError(String) + case quoteCalcFailed +} diff --git a/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubExtrinsicService.swift b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubExtrinsicService.swift new file mode 100644 index 0000000000..246bd1dc29 --- /dev/null +++ b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubExtrinsicService.swift @@ -0,0 +1,73 @@ +import Foundation +import SubstrateSdk + +final class AssetHubExtrinsicService { + let chain: ChainModel + + init(chain: ChainModel) { + self.chain = chain + } + + private func fetchExtrinsicBuilderClosure( + for args: AssetConversion.CallArgs, + codingFactory: RuntimeCoderFactoryProtocol, + chain: ChainModel + ) -> ExtrinsicBuilderClosure { + { builder in + + guard + let remoteAssetIn = AssetHubTokensConverter.convertToMultilocation( + chainAssetId: args.assetIn, + chain: chain, + codingFactory: codingFactory + ) else { + throw AssetConversionExtrinsicServiceError.remoteAssetNotFound(args.assetIn) + } + + guard + let remoteAssetOut = AssetHubTokensConverter.convertToMultilocation( + chainAssetId: args.assetOut, + chain: chain, + codingFactory: codingFactory + ) else { + throw AssetConversionExtrinsicServiceError.remoteAssetNotFound(args.assetOut) + } + + switch args.direction { + case .sell: + let amountOutMin = args.amountOut - args.slippage.mul(value: args.amountOut) + + let call = AssetConversionPallet.SwapExactTokensForTokensCall( + path: [remoteAssetIn, remoteAssetOut], + amountIn: args.amountIn, + amountOutMin: amountOutMin, + sendTo: args.receiver, + keepAlive: false + ) + + return try builder.adding(call: call.runtimeCall(for: AssetConversionPallet.name)) + case .buy: + let amountInMax = args.amountIn + args.slippage.mul(value: args.amountIn) + + let call = AssetConversionPallet.SwapTokensForExactTokensCall( + path: [remoteAssetIn, remoteAssetOut], + amountOut: args.amountOut, + amountInMax: amountInMax, + sendTo: args.receiver, + keepAlive: false + ) + + return try builder.adding(call: call.runtimeCall(for: AssetConversionPallet.name)) + } + } + } +} + +extension AssetHubExtrinsicService: AssetConversionExtrinsicServiceProtocol { + func fetchExtrinsicBuilderClosure( + for args: AssetConversion.CallArgs, + codingFactory: RuntimeCoderFactoryProtocol + ) -> ExtrinsicBuilderClosure { + fetchExtrinsicBuilderClosure(for: args, codingFactory: codingFactory, chain: chain) + } +} diff --git a/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubFeeService.swift b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubFeeService.swift new file mode 100644 index 0000000000..b6d37af77b --- /dev/null +++ b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubFeeService.swift @@ -0,0 +1,374 @@ +import Foundation +import RobinHood +import BigInt + +final class AssetHubFeeService: AnyCancellableCleaning { + struct ChainOperationFactory { + let extrinsicServiceFactory: ExtrinsicServiceFactoryProtocol + let conversionOperationFactory: AssetConversionOperationFactoryProtocol + let conversionExtrinsicService: AssetConversionExtrinsicServiceProtocol + } + + let wallet: MetaAccountModel + let chainRegistry: ChainRegistryProtocol + let operationQueue: OperationQueue + + private var factories: ChainOperationFactory? + private var chainId: ChainModel.Id? + private var cancellableCall: CancellableCall? + private var lock = NSLock() + + init( + wallet: MetaAccountModel, + chainRegistry: ChainRegistryProtocol, + operationQueue: OperationQueue + ) { + self.wallet = wallet + self.chainRegistry = chainRegistry + self.operationQueue = operationQueue + } + + private func updateFactories(for chain: ChainModel) throws -> ChainOperationFactory { + if chain.chainId == chainId, let factories = factories { + return factories + } + + factories = nil + chainId = nil + + guard let connection = chainRegistry.getConnection(for: chain.chainId) else { + throw AssetConversionFeeServiceError.chainConnectionMissing + } + + guard let runtimeProvider = chainRegistry.getRuntimeProvider(for: chain.chainId) else { + throw AssetConversionFeeServiceError.chainRuntimeMissing + } + + let extrinsicServiceFactory = ExtrinsicServiceFactory( + runtimeRegistry: runtimeProvider, + engine: connection, + operationManager: OperationManager(operationQueue: operationQueue) + ) + + let conversionOperationFactory = AssetHubSwapOperationFactory( + chain: chain, + runtimeService: runtimeProvider, + connection: connection, + operationQueue: operationQueue + ) + + let conversionExtrinsicService = AssetHubExtrinsicService(chain: chain) + + let factories = ChainOperationFactory( + extrinsicServiceFactory: extrinsicServiceFactory, + conversionOperationFactory: conversionOperationFactory, + conversionExtrinsicService: conversionExtrinsicService + ) + + self.factories = factories + chainId = chain.chainId + + return factories + } + + private func performCalculation( + in asset: ChainAsset, + callArgs: AssetConversion.CallArgs, + runCompletionIn queue: DispatchQueue, + completion closure: @escaping AssetConversionFeeServiceClosure + ) throws { + guard let runtimeProvider = chainRegistry.getRuntimeProvider(for: asset.chain.chainId) else { + throw AssetConversionFeeServiceError.chainRuntimeMissing + } + + guard let utilityAsset = asset.chain.utilityAsset() else { + throw AssetConversionFeeServiceError.utilityAssetMissing + } + + let utilityChainAsset = ChainAsset(chain: asset.chain, asset: utilityAsset) + + let factories = try updateFactories(for: asset.chain) + + let nativeFeeWrapper = createNativeFeeWrapper( + for: callArgs, + runtimeProvider: runtimeProvider, + extrinsicServiceFactory: factories.extrinsicServiceFactory, + conversionExtrinsicService: factories.conversionExtrinsicService, + wallet: wallet, + asset: asset + ) + + let universalFeeWrapper: CompoundOperationWrapper + + if asset.isUtilityAsset { + universalFeeWrapper = createNativeTokenFeeCalculationWrapper(using: nativeFeeWrapper) + } else { + universalFeeWrapper = createCustomTokenFeeCalculationWrapper( + in: asset, + utilityAsset: utilityChainAsset, + nativeFeeWrapper: nativeFeeWrapper, + runtimeProvider: runtimeProvider, + conversionOperationFactory: factories.conversionOperationFactory + ) + } + + universalFeeWrapper.targetOperation.completionBlock = { [weak self] in + dispatchInQueueWhenPossible(queue) { + guard let self = self, self.completeOrIgnore(wrapper: universalFeeWrapper) else { + return + } + + do { + let model = try universalFeeWrapper.targetOperation.extractNoCancellableResultData() + closure(.success(model)) + } catch let error as AssetConversionFeeServiceError { + closure(.failure(error)) + } catch { + closure(.failure(.calculationFailed("Fee calculation failed \(asset.chain.name): \(error)"))) + } + } + } + + cancellableCall = universalFeeWrapper + + operationQueue.addOperations(universalFeeWrapper.allOperations, waitUntilFinished: false) + } + + // swiftlint:disable:next function_parameter_count + private func createNativeFeeWrapper( + for callArgs: AssetConversion.CallArgs, + runtimeProvider: RuntimeProviderProtocol, + extrinsicServiceFactory: ExtrinsicServiceFactoryProtocol, + conversionExtrinsicService: AssetConversionExtrinsicServiceProtocol, + wallet: MetaAccountModel, + asset: ChainAsset + ) -> CompoundOperationWrapper { + let coderFactoryOperation = runtimeProvider.fetchCoderFactoryOperation() + + let mainFeeOperation = OperationCombiningService( + operationManager: OperationManager(operationQueue: operationQueue) + ) { + guard let account = wallet.fetch(for: asset.chain.accountRequest()) else { + throw AssetConversionFeeServiceError.accountMissing + } + + let coderFactory = try coderFactoryOperation.extractNoCancellableResultData() + + let extrinsicOperationFactory: ExtrinsicOperationFactoryProtocol + + if asset.isUtilityAsset { + extrinsicOperationFactory = extrinsicServiceFactory.createOperationFactory( + account: account, + chain: asset.chain + ) + } else { + guard let assetId = AssetHubTokensConverter.convertToMultilocation( + chainAsset: asset, + codingFactory: coderFactory + ) else { + throw AssetConversionFeeServiceError.feeAssetConversionFailed + } + + extrinsicOperationFactory = extrinsicServiceFactory.createOperationFactory( + account: account, + chain: asset.chain, + feeAssetConversionId: assetId + ) + } + + let feeWrapper = extrinsicOperationFactory.estimateFeeOperation { builder in + let codingFactory = try coderFactoryOperation.extractNoCancellableResultData() + + let builderSetupClosure = conversionExtrinsicService.fetchExtrinsicBuilderClosure( + for: callArgs, + codingFactory: codingFactory + ) + + return try builderSetupClosure(builder) + } + + return [feeWrapper] + }.longrunOperation() + + let mappingOperation = ClosureOperation { + guard let feeModel = try mainFeeOperation.extractNoCancellableResultData().first else { + throw CommonError.dataCorruption + } + + guard let fee = BigUInt(feeModel.fee) else { + throw CommonError.dataCorruption + } + + return fee + } + + mainFeeOperation.addDependency(coderFactoryOperation) + mappingOperation.addDependency(mainFeeOperation) + + return .init( + targetOperation: mappingOperation, + dependencies: [coderFactoryOperation, mainFeeOperation] + ) + } + + private func createNativeTokenFeeCalculationWrapper( + using nativeFeeWrapper: CompoundOperationWrapper + ) -> CompoundOperationWrapper { + let resultOperation = ClosureOperation { + let feeAmount = try nativeFeeWrapper.targetOperation.extractNoCancellableResultData() + + let model = AssetConversion.AmountWithNative(targetAmount: feeAmount, nativeAmount: feeAmount) + + return .init(totalFee: model, networkFee: model) + } + + resultOperation.addDependency(nativeFeeWrapper.targetOperation) + + return nativeFeeWrapper.insertingTail(operation: resultOperation) + } + + private func createCustomTokenFeeCalculationWrapper( + in feeAsset: ChainAsset, + utilityAsset: ChainAsset, + nativeFeeWrapper: CompoundOperationWrapper, + runtimeProvider: RuntimeProviderProtocol, + conversionOperationFactory: AssetConversionOperationFactoryProtocol + ) -> CompoundOperationWrapper { + let edWrapper = AssetStorageInfoOperationFactory( + chainRegistry: chainRegistry, + operationQueue: operationQueue + ).createAssetBalanceExistenceOperation( + chainId: utilityAsset.chain.chainId, + asset: utilityAsset.asset, + runtimeProvider: runtimeProvider, + operationQueue: operationQueue + ) + + let feeWithEdOperation = ClosureOperation<(BigUInt, BigUInt)> { + let feeAmount = try nativeFeeWrapper.targetOperation.extractNoCancellableResultData() + let edAmount = try edWrapper.targetOperation.extractNoCancellableResultData().minBalance + + return (feeAmount, edAmount) + } + + feeWithEdOperation.addDependency(nativeFeeWrapper.targetOperation) + feeWithEdOperation.addDependency(edWrapper.targetOperation) + + let quoteOperation = createQuoteForCustomTokenWrapper( + for: feeAsset, + utilityAsset: utilityAsset, + conversionOperationFactory: conversionOperationFactory, + feeWithEdOperation: feeWithEdOperation + ) + + quoteOperation.addDependency(feeWithEdOperation) + + let mergeOperation = ClosureOperation { + let (feeAmount, edAmount) = try feeWithEdOperation.extractNoCancellableResultData() + + let quotes = try quoteOperation.extractNoCancellableResultData() + + return .init( + totalFee: .init( + targetAmount: quotes[0].amountIn, + nativeAmount: feeAmount + edAmount + ), + networkFee: .init( + targetAmount: quotes[1].amountIn, + nativeAmount: feeAmount + ) + ) + } + + mergeOperation.addDependency(feeWithEdOperation) + mergeOperation.addDependency(quoteOperation) + + let dependencies = nativeFeeWrapper.allOperations + edWrapper.allOperations + + [feeWithEdOperation, quoteOperation] + + return .init(targetOperation: mergeOperation, dependencies: dependencies) + } + + private func createQuoteForCustomTokenWrapper( + for feeAsset: ChainAsset, + utilityAsset: ChainAsset, + conversionOperationFactory: AssetConversionOperationFactoryProtocol, + feeWithEdOperation: BaseOperation<(BigUInt, BigUInt)> + ) -> BaseOperation<[AssetConversion.Quote]> { + OperationCombiningService( + operationManager: OperationManager(operationQueue: operationQueue) + ) { + let (fee, edAmount) = try feeWithEdOperation.extractNoCancellableResultData() + + let feeWithAdditionsQuoteWrapper = conversionOperationFactory.quote( + for: .init( + assetIn: feeAsset.chainAssetId, + assetOut: utilityAsset.chainAssetId, + amount: fee + edAmount, + direction: .buy + ) + ) + + let feeQuoteWrapper = conversionOperationFactory.quote( + for: .init( + assetIn: feeAsset.chainAssetId, + assetOut: utilityAsset.chainAssetId, + amount: fee, + direction: .buy + ) + ) + + return [feeWithAdditionsQuoteWrapper, feeQuoteWrapper] + }.longrunOperation() + } + + private func completeOrIgnore(wrapper: CompoundOperationWrapper) -> Bool { + lock.lock() + + defer { + lock.unlock() + } + + guard cancellableCall === wrapper else { + return false + } + + cancellableCall = nil + + return true + } +} + +extension AssetHubFeeService: AssetConversionFeeServiceProtocol { + func calculate( + in asset: ChainAsset, + callArgs: AssetConversion.CallArgs, + runCompletionIn queue: DispatchQueue, + completion closure: @escaping AssetConversionFeeServiceClosure + ) { + do { + lock.lock() + + defer { + lock.unlock() + } + + clear(cancellable: &cancellableCall) + + try performCalculation( + in: asset, + callArgs: callArgs, + runCompletionIn: queue, + completion: closure + ) + } catch let error as AssetConversionFeeServiceError { + dispatchInQueueWhenPossible(queue) { + closure(.failure(error)) + } + } catch { + dispatchInQueueWhenPossible(queue) { + closure(.failure(.setupFailed("Fee service setup failed for \(asset.chain.name): \(error)"))) + } + } + } +} diff --git a/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubSwapOperationFactory.swift b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubSwapOperationFactory.swift new file mode 100644 index 0000000000..ad28e3a795 --- /dev/null +++ b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubSwapOperationFactory.swift @@ -0,0 +1,237 @@ +import Foundation +import RobinHood +import SubstrateSdk +import BigInt + +final class AssetHubSwapOperationFactory { + static let sellQuoteApi = "AssetConversionApi_quote_price_exact_tokens_for_tokens" + static let buyQuoteApi = "AssetConversionApi_quote_price_exact_tokens_for_tokens" + + let chain: ChainModel + let runtimeService: RuntimeCodingServiceProtocol + let connection: JSONRPCEngine + let operationQueue: OperationQueue + + init( + chain: ChainModel, + runtimeService: RuntimeCodingServiceProtocol, + connection: JSONRPCEngine, + operationQueue: OperationQueue + ) { + self.chain = chain + self.runtimeService = runtimeService + self.connection = connection + self.operationQueue = operationQueue + } + + private func fetchAllPairsWrapper( + dependingOn codingFactoryOperation: BaseOperation, + chain: ChainModel + ) -> CompoundOperationWrapper<[AssetConversionPallet.PoolAssetPair]> { + let prefixEncodingOperation = UnkeyedEncodingOperation( + path: AssetConversionPallet.poolsPath, + storageKeyFactory: StorageKeyFactory() + ) + + prefixEncodingOperation.configurationBlock = { + do { + prefixEncodingOperation.codingFactory = try codingFactoryOperation.extractNoCancellableResultData() + } catch { + prefixEncodingOperation.result = .failure(error) + } + } + + let keysFetchOperation = StorageKeysQueryService( + connection: connection, + operationManager: OperationManager(operationQueue: operationQueue), + prefixKeyClosure: { try prefixEncodingOperation.extractNoCancellableResultData() }, + mapper: AnyMapper(mapper: IdentityMapper()) + ).longrunOperation() + + keysFetchOperation.addDependency(prefixEncodingOperation) + + let decodingOperation = StorageKeyDecodingOperation( + path: AssetConversionPallet.poolsPath + ) + + decodingOperation.configurationBlock = { + do { + decodingOperation.codingFactory = try codingFactoryOperation.extractNoCancellableResultData() + decodingOperation.dataList = try keysFetchOperation.extractNoCancellableResultData() + } catch { + decodingOperation.result = .failure(error) + } + } + + decodingOperation.addDependency(keysFetchOperation) + + let mappingOperation = ClosureOperation<[AssetConversionPallet.PoolAssetPair]> { + let decodedPairs = try decodingOperation.extractNoCancellableResultData() + + return decodedPairs.map { assetIdPair in + let asset1 = AssetHubTokensConverter.convertFromMultilocation( + assetIdPair.asset1, + chain: chain + ) + + let asset2 = AssetHubTokensConverter.convertFromMultilocation( + assetIdPair.asset2, + chain: chain + ) + + return .init(asset1: asset1, asset2: asset2) + } + } + + mappingOperation.addDependency(decodingOperation) + + return CompoundOperationWrapper( + targetOperation: mappingOperation, + dependencies: [prefixEncodingOperation, keysFetchOperation, decodingOperation] + ) + } + + private func mapRemotePairsOperation( + for chain: ChainModel, + dependingOn codingFactoryOperation: BaseOperation, + remotePairsOperation: BaseOperation<[AssetConversionPallet.PoolAssetPair]> + ) -> BaseOperation<[ChainAssetId: Set]> { + ClosureOperation<[ChainAssetId: Set]> { + let codingFactory = try codingFactoryOperation.extractNoCancellableResultData() + let remotePairs = try remotePairsOperation.extractNoCancellableResultData() + + let optNativeAsset = chain.utilityAsset() + + let initAssetsStore = [JSON: (AssetModel, AssetsPalletStorageInfo)]() + let assetsPalletTokens = chain.assets.reduce(into: initAssetsStore) { store, asset in + let optStorageInfo = try? AssetStorageInfo.extract(from: asset, codingFactory: codingFactory) + guard case let .statemine(info) = optStorageInfo else { + return + } + + store[info.assetId] = (asset, info) + } + + let mappingClosure: (AssetConversionPallet.PoolAsset) -> ChainAssetId? = { remoteAsset in + switch remoteAsset { + case .native: + if let nativeAsset = optNativeAsset { + return ChainAssetId(chainId: chain.chainId, assetId: nativeAsset.assetId) + } else { + return nil + } + case let .assets(pallet, index): + guard let localToken = assetsPalletTokens[.stringValue(String(index))] else { + return nil + } + + let palletName = localToken.1.palletName ?? PalletAssets.name + + guard + let moduleIndex = codingFactory.metadata.getModuleIndex(palletName), + moduleIndex == pallet else { + // only Assets pallet currently supported + return nil + } + + return ChainAssetId(chainId: chain.chainId, assetId: localToken.0.assetId) + case let .foreign(remoteId): + guard + let json = try? remoteId.toScaleCompatibleJSON(), + let localToken = assetsPalletTokens[json] else { + return nil + } + + return ChainAssetId(chainId: chain.chainId, assetId: localToken.0.assetId) + default: + return nil + } + } + + let initPairsStore = [ChainAssetId: Set]() + let result = remotePairs.reduce(into: initPairsStore) { store, remotePair in + guard + let asset1 = mappingClosure(remotePair.asset1), + let asset2 = mappingClosure(remotePair.asset2) else { + return + } + + store[asset1] = Set([asset2]).union(store[asset1] ?? []) + store[asset2] = Set([asset1]).union(store[asset2] ?? []) + } + + return result + } + } +} + +extension AssetHubSwapOperationFactory: AssetConversionOperationFactoryProtocol { + func availableDirections() -> CompoundOperationWrapper<[ChainAssetId: Set]> { + let codingFactoryOperation = runtimeService.fetchCoderFactoryOperation() + let fetchRemoteWrapper = fetchAllPairsWrapper(dependingOn: codingFactoryOperation, chain: chain) + let mappingOperation = mapRemotePairsOperation( + for: chain, + dependingOn: codingFactoryOperation, + remotePairsOperation: fetchRemoteWrapper.targetOperation + ) + + fetchRemoteWrapper.addDependency(operations: [codingFactoryOperation]) + mappingOperation.addDependency(fetchRemoteWrapper.targetOperation) + + let dependencies = [codingFactoryOperation] + fetchRemoteWrapper.allOperations + + return CompoundOperationWrapper(targetOperation: mappingOperation, dependencies: dependencies) + } + + func availableDirectionsForAsset(_ chainAssetId: ChainAssetId) -> CompoundOperationWrapper> { + let allDirectionsWrapper = availableDirections() + + let mappingOperation = ClosureOperation> { + let allChainAssets = try allDirectionsWrapper.targetOperation.extractNoCancellableResultData() + + return allChainAssets[chainAssetId] ?? [] + } + + mappingOperation.addDependency(allDirectionsWrapper.targetOperation) + + return CompoundOperationWrapper( + targetOperation: mappingOperation, + dependencies: allDirectionsWrapper.allOperations + ) + } + + func quote(for args: AssetConversion.QuoteArgs) -> CompoundOperationWrapper { + let codingFactoryOperation = runtimeService.fetchCoderFactoryOperation() + + let request = AssetHubSwapRequestBuilder(chain: chain).build(args: args) { + try codingFactoryOperation.extractNoCancellableResultData() + } + + let quoteOperation = JSONRPCOperation( + engine: connection, + method: StateCallRpc.method + ) + + quoteOperation.parameters = request + + quoteOperation.addDependency(codingFactoryOperation) + + let mappingOperation = ClosureOperation { + let responseString = try quoteOperation.extractNoCancellableResultData() + let codingFactory = try codingFactoryOperation.extractNoCancellableResultData() + + let amount = try AssetHubSwapRequestSerializer.deserialize( + quoteResponse: responseString, + codingFactory: codingFactory + ) + + return .init(args: args, amount: amount) + } + + mappingOperation.addDependency(quoteOperation) + + let dependencies = [codingFactoryOperation, quoteOperation] + + return CompoundOperationWrapper(targetOperation: mappingOperation, dependencies: dependencies) + } +} diff --git a/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubSwapQuoteBuilder.swift b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubSwapQuoteBuilder.swift new file mode 100644 index 0000000000..b209408c64 --- /dev/null +++ b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubSwapQuoteBuilder.swift @@ -0,0 +1,167 @@ +import Foundation +import SubstrateSdk +import BigInt + +enum AssetHubSwapRequestSerializer { + private static func extractAssetType(from codingFactory: RuntimeCoderFactoryProtocol) -> String? { + guard + let call = codingFactory.getCall( + for: AssetConversionPallet.addLiquidityCallPath(for: AssetConversionPallet.name) + ), + !call.arguments.isEmpty else { + return nil + } + + return call.arguments[0].type + } + + private static func extractBalanceType(from codingFactory: RuntimeCoderFactoryProtocol) -> String? { + guard + let call = codingFactory.getCall( + for: AssetConversionPallet.addLiquidityCallPath(for: AssetConversionPallet.name) + ), + call.arguments.count > 2 else { + return nil + } + + return call.arguments[2].type + } + + static func serialize( + asset: AssetConversionPallet.AssetId, + into encoder: inout DynamicScaleEncoding, + codingFactory: RuntimeCoderFactoryProtocol + ) throws { + guard let assetType = extractAssetType(from: codingFactory) else { + throw AssetConversionOperationError.runtimeError("undefined asset type") + } + + try encoder.append(asset, ofType: assetType, with: codingFactory.createRuntimeJsonContext().toRawContext()) + } + + static func serialize( + amount: BigUInt, + into encoder: inout DynamicScaleEncoding, + codingFactory: RuntimeCoderFactoryProtocol + ) throws { + guard let balanceType = extractBalanceType(from: codingFactory) else { + throw AssetConversionOperationError.runtimeError("undefined balance type") + } + + try encoder.append( + StringScaleMapper(value: amount), + ofType: balanceType, + with: codingFactory.createRuntimeJsonContext().toRawContext() + ) + } + + static func deserialize(quoteResponse: String, codingFactory: RuntimeCoderFactoryProtocol) throws -> BigUInt { + guard let balanceType = extractBalanceType(from: codingFactory) else { + throw AssetConversionOperationError.runtimeError("undefined balance type") + } + + let data = try Data(hexString: quoteResponse) + + let decoder = try codingFactory.createDecoder(from: data) + + let json: JSON = try decoder.readOption(type: balanceType) + + guard json != .null else { + throw AssetConversionOperationError.quoteCalcFailed + } + + return try json.map( + to: StringScaleMapper.self, + with: codingFactory.createRuntimeJsonContext().toRawContext() + ).value + } +} + +final class AssetHubSwapRequestBuilder { + static let sellQuoteApi = "AssetConversionApi_quote_price_exact_tokens_for_tokens" + static let buyQuoteApi = "AssetConversionApi_quote_price_tokens_for_exact_tokens" + + let chain: ChainModel + + init(chain: ChainModel) { + self.chain = chain + } + + private func createRequest( + for chain: ChainModel, + args: AssetConversion.QuoteArgs, + builtInFunction: String, + codingClosure: @escaping () throws -> RuntimeCoderFactoryProtocol, + includesFee: Bool + ) -> StateCallRpc.Request { + StateCallRpc.Request(builtInFunction: builtInFunction) { container in + let codingFactory = try codingClosure() + + guard + let remoteAssetIn = AssetHubTokensConverter.convertToMultilocation( + chainAssetId: args.assetIn, + chain: chain, + codingFactory: codingFactory + ) else { + throw AssetConversionOperationError.remoteAssetNotFound(args.assetIn) + } + + guard + let remoteAssetOut = AssetHubTokensConverter.convertToMultilocation( + chainAssetId: args.assetOut, + chain: chain, + codingFactory: codingFactory + ) else { + throw AssetConversionOperationError.remoteAssetNotFound(args.assetOut) + } + + var encoder = codingFactory.createEncoder() + + try AssetHubSwapRequestSerializer.serialize( + asset: remoteAssetIn, + into: &encoder, + codingFactory: codingFactory + ) + + try AssetHubSwapRequestSerializer.serialize( + asset: remoteAssetOut, + into: &encoder, + codingFactory: codingFactory + ) + + try AssetHubSwapRequestSerializer.serialize( + amount: args.amount, + into: &encoder, + codingFactory: codingFactory + ) + + try encoder.appendBool(json: .boolValue(includesFee)) + + let data = try encoder.encode() + + try container.encode(data.toHex(includePrefix: true)) + } + } + + func build( + args: AssetConversion.QuoteArgs, + codingClosure: @escaping () throws -> RuntimeCoderFactoryProtocol + ) -> StateCallRpc.Request { + let builtInFunction: String + + switch args.direction { + case .sell: + builtInFunction = Self.sellQuoteApi + case .buy: + builtInFunction = Self.buyQuoteApi + } + + return createRequest( + for: chain, + args: args, + builtInFunction: builtInFunction, + codingClosure: codingClosure, + includesFee: true + ) + } +} diff --git a/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubTokensConverter.swift b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubTokensConverter.swift new file mode 100644 index 0000000000..4ddd9f99e8 --- /dev/null +++ b/novawallet/Modules/AssetConversion/Service/AssetHub/AssetHubTokensConverter.swift @@ -0,0 +1,164 @@ +import Foundation +import SubstrateSdk +import BigInt + +struct AssetHubToken { + let assetId: ChainAssetId + let extras: StatemineAssetExtras +} + +enum AssetHubTokensConverter { + static func convertToMultilocation( + chainAssetId: ChainAssetId, + chain: ChainModel, + codingFactory: RuntimeCoderFactoryProtocol + ) -> AssetConversionPallet.AssetId? { + guard + chain.chainId == chainAssetId.chainId, + let localAsset = chain.asset(for: chainAssetId.assetId) else { + return nil + } + + return convertToMultilocation( + chainAsset: ChainAsset(chain: chain, asset: localAsset), + codingFactory: codingFactory + ) + } + + static func convertFromMultilocation( + _ assetId: AssetConversionPallet.AssetId, + chain: ChainModel + ) -> AssetConversionPallet.PoolAsset { + let junctions = assetId.interior.items + + if assetId.parents == 0 { + guard !junctions.isEmpty else { + return .native + } + + switch junctions[0] { + case let .palletInstance(pallet): + if + junctions.count == 2, + case let .generalIndex(index) = junctions[1] { + return .assets(pallet: pallet, index: index) + } else { + return .undefined(assetId) + } + default: + return .undefined(assetId) + } + } else if assetId.parents == 1, junctions.isEmpty, chain.isUtilityTokenOnRelaychain { + return .native + } else { + return .foreign(assetId) + } + } + + static func convertFromMultilocationToLocal( + _ assetId: AssetConversionPallet.AssetId, + chain: ChainModel, + conversionClosure: (AssetConversionPallet.PoolAsset) -> ChainAsset? + ) -> ChainAsset? { + let poolAsset = convertFromMultilocation(assetId, chain: chain) + + return conversionClosure(poolAsset) + } + + static func convertToMultilocation( + chainAsset: ChainAsset, + codingFactory: RuntimeCoderFactoryProtocol + ) -> AssetConversionPallet.AssetId? { + guard + let storageInfo = try? AssetStorageInfo.extract( + from: chainAsset.asset, + codingFactory: codingFactory + ) else { + return nil + } + + switch storageInfo { + case .native: + if chainAsset.chain.isUtilityTokenOnRelaychain { + return .init(parents: 1, interior: .init(items: [])) + } else { + return .init(parents: 0, interior: .init(items: [])) + } + case let .statemine(info): + if info.assetIdString.isHex() { + let remoteAssetId = try? info.assetId.map( + to: AssetConversionPallet.AssetId.self, + with: codingFactory.createRuntimeJsonContext().toRawContext() + ) + + return remoteAssetId + } else { + let palletName = info.palletName ?? PalletAssets.name + + guard + let palletIndex = codingFactory.metadata.getModuleIndex(palletName), + let generalIndex = BigUInt(info.assetIdString) else { + return nil + } + + let palletJunction = XcmV3.Junction.palletInstance(palletIndex) + let generalIndexJunction = XcmV3.Junction.generalIndex(generalIndex) + + return .init(parents: 0, interior: .init(items: [palletJunction, generalIndexJunction])) + } + default: + return nil + } + } + + static func createPoolAssetToLocalClosure( + for chain: ChainModel, + codingFactory: RuntimeCoderFactoryProtocol + ) -> (AssetConversionPallet.PoolAsset) -> ChainAsset? { + let initAssetsStore = [JSON: (AssetModel, AssetsPalletStorageInfo)]() + let assetsPalletTokens = chain.assets.reduce(into: initAssetsStore) { store, asset in + let optStorageInfo = try? AssetStorageInfo.extract(from: asset, codingFactory: codingFactory) + guard case let .statemine(info) = optStorageInfo else { + return + } + + store[info.assetId] = (asset, info) + } + + return { remoteAsset in + switch remoteAsset { + case .native: + return chain.utilityChainAsset() + case let .assets(pallet, index): + guard let localToken = assetsPalletTokens[.stringValue(String(index))] else { + return nil + } + + let palletName = localToken.1.palletName ?? PalletAssets.name + + guard + let moduleIndex = codingFactory.metadata.getModuleIndex(palletName), + moduleIndex == pallet else { + // only Assets pallet currently supported + return nil + } + + return chain.asset(for: localToken.0.assetId).map { asset in + ChainAsset(chain: chain, asset: asset) + } + case let .foreign(remoteId): + guard + let json = try? remoteId.toScaleCompatibleJSON(), + let localToken = assetsPalletTokens[json] else { + return nil + } + + return chain.asset(for: localToken.0.assetId).map { asset in + ChainAsset(chain: chain, asset: asset) + } + default: + return nil + } + } + } +} diff --git a/novawallet/Modules/AssetDetails/AssetDetailsInteractor.swift b/novawallet/Modules/AssetDetails/AssetDetailsInteractor.swift index 10ffe0bbe4..e358285c6d 100644 --- a/novawallet/Modules/AssetDetails/AssetDetailsInteractor.swift +++ b/novawallet/Modules/AssetDetails/AssetDetailsInteractor.swift @@ -1,20 +1,23 @@ import UIKit import RobinHood -final class AssetDetailsInteractor { +final class AssetDetailsInteractor: AnyCancellableCleaning { weak var presenter: AssetDetailsInteractorOutputProtocol? let chainAsset: ChainAsset let selectedMetaAccount: MetaAccountModel let walletLocalSubscriptionFactory: WalletLocalSubscriptionFactoryProtocol let priceLocalSubscriptionFactory: PriceProviderFactoryProtocol let externalBalancesSubscriptionFactory: ExternalBalanceLocalSubscriptionFactoryProtocol + let assetConvertionAggregator: AssetConversionAggregationFactoryProtocol let purchaseProvider: PurchaseProviderProtocol let assetMapper: CustomAssetMapper + let operationQueue: OperationQueue private var assetLocksSubscription: StreamableProvider? private var priceSubscription: StreamableProvider? private var assetBalanceSubscription: StreamableProvider? private var externalBalanceSubscription: StreamableProvider? + private var swapsCall = CancellableCallStore() private var accountId: AccountId? { selectedMetaAccount.fetch(for: chainAsset.chain.accountRequest())?.accountId @@ -27,6 +30,8 @@ final class AssetDetailsInteractor { walletLocalSubscriptionFactory: WalletLocalSubscriptionFactoryProtocol, priceLocalSubscriptionFactory: PriceProviderFactoryProtocol, externalBalancesSubscriptionFactory: ExternalBalanceLocalSubscriptionFactoryProtocol, + assetConvertionAggregator: AssetConversionAggregationFactoryProtocol, + operationQueue: OperationQueue, currencyManager: CurrencyManagerProtocol ) { self.walletLocalSubscriptionFactory = walletLocalSubscriptionFactory @@ -35,6 +40,8 @@ final class AssetDetailsInteractor { self.selectedMetaAccount = selectedMetaAccount self.chainAsset = chainAsset self.purchaseProvider = purchaseProvider + self.assetConvertionAggregator = assetConvertionAggregator + self.operationQueue = operationQueue assetMapper = CustomAssetMapper( type: chainAsset.asset.type, typeExtras: chainAsset.asset.typeExtras @@ -42,6 +49,10 @@ final class AssetDetailsInteractor { self.currencyManager = currencyManager } + deinit { + swapsCall.cancel() + } + private func subscribePrice() { if let priceId = chainAsset.asset.priceId { priceSubscription = subscribeToPrice(for: priceId, currency: selectedCurrency) @@ -50,7 +61,36 @@ final class AssetDetailsInteractor { } } - private func setAvailableOperations() { + private func fetchSwapsAndProvideOperations() { + swapsCall.cancel() + + guard chainAsset.chain.hasSwaps else { + return + } + + let wrapper = assetConvertionAggregator.createAvailableDirectionsWrapper(for: chainAsset) + + executeCancellable( + wrapper: wrapper, + inOperationQueue: operationQueue, + backingCallIn: swapsCall, + runningCallbackIn: .main + ) { [weak self] result in + guard let self = self else { + return + } + + switch result { + case let .success(directions): + let hasSwaps = !directions.isEmpty + self.setAvailableOperations(hasSwaps: hasSwaps) + case let .failure(error): + self.presenter?.didReceive(error: .swaps(error)) + } + } + } + + private func setAvailableOperations(hasSwaps: Bool) { guard let accountId = accountId else { return } @@ -71,6 +111,10 @@ final class AssetDetailsInteractor { operations.insert(.receive) + if hasSwaps { + operations.insert(.swap) + } + presenter?.didReceive(purchaseActions: actions) presenter?.didReceive(availableOperations: operations) } @@ -102,7 +146,11 @@ extension AssetDetailsInteractor: AssetDetailsInteractorInputProtocol { externalBalanceSubscription = nil } - setAvailableOperations() + setAvailableOperations(hasSwaps: false) + + if chainAsset.chain.hasSwaps { + fetchSwapsAndProvideOperations() + } } } @@ -126,7 +174,7 @@ extension AssetDetailsInteractor: WalletLocalStorageSubscriber, WalletLocalSubsc accountId: accountId )) case let .failure(error): - presenter?.didReceive(error: .accountBalance(error)) + presenter?.didReceive(error: .swaps(error)) } } diff --git a/novawallet/Modules/AssetDetails/AssetDetailsPresenter.swift b/novawallet/Modules/AssetDetails/AssetDetailsPresenter.swift index a3042f1a2c..75dbf29a8d 100644 --- a/novawallet/Modules/AssetDetails/AssetDetailsPresenter.swift +++ b/novawallet/Modules/AssetDetails/AssetDetailsPresenter.swift @@ -195,6 +195,10 @@ extension AssetDetailsPresenter: AssetDetailsPresenterProtocol { ) wireframe.showLocks(from: view, model: model) } + + func handleSwap() { + wireframe.showSwaps(from: view, chainAsset: chainAsset) + } } extension AssetDetailsPresenter: AssetDetailsInteractorOutputProtocol { @@ -229,7 +233,7 @@ extension AssetDetailsPresenter: AssetDetailsInteractorOutputProtocol { } func didReceive(error: AssetDetailsError) { - logger?.error(error.localizedDescription) + logger?.error("Did receive error: \(error)") } } diff --git a/novawallet/Modules/AssetDetails/AssetDetailsProtocols.swift b/novawallet/Modules/AssetDetails/AssetDetailsProtocols.swift index b02c76bb10..ab3c545647 100644 --- a/novawallet/Modules/AssetDetails/AssetDetailsProtocols.swift +++ b/novawallet/Modules/AssetDetails/AssetDetailsProtocols.swift @@ -14,6 +14,7 @@ protocol AssetDetailsPresenterProtocol: AnyObject { func handleReceive() func handleBuy() func handleLocks() + func handleSwap() } protocol AssetDetailsInteractorInputProtocol: AnyObject { @@ -40,6 +41,7 @@ protocol AssetDetailsWireframeProtocol: AnyObject, PurchasePresentable, AlertPre func showNoSigning(from view: AssetDetailsViewProtocol?) func showLedgerNotSupport(for tokenName: String, from view: AssetDetailsViewProtocol?) func showLocks(from view: AssetDetailsViewProtocol?, model: AssetDetailsLocksViewModel) + func showSwaps(from view: AssetDetailsViewProtocol?, chainAsset: ChainAsset) } enum AssetDetailsError: Error { @@ -47,4 +49,5 @@ enum AssetDetailsError: Error { case price(Error) case locks(Error) case externalBalances(Error) + case swaps(Error) } diff --git a/novawallet/Modules/AssetDetails/AssetDetailsViewController.swift b/novawallet/Modules/AssetDetails/AssetDetailsViewController.swift index 6f83c399be..2aa2b77440 100644 --- a/novawallet/Modules/AssetDetails/AssetDetailsViewController.swift +++ b/novawallet/Modules/AssetDetails/AssetDetailsViewController.swift @@ -40,6 +40,7 @@ final class AssetDetailsViewController: UIViewController, ViewHolder { rootView.sendButton.addTarget(self, action: #selector(didTapSendButton), for: .touchUpInside) rootView.receiveButton.addTarget(self, action: #selector(didTapReceiveButton), for: .touchUpInside) rootView.buyButton.addTarget(self, action: #selector(didTapBuyButton), for: .touchUpInside) + rootView.swapButton.addTarget(self, action: #selector(didTapSwapButton), for: .touchUpInside) rootView.lockCell.addTarget(self, action: #selector(didTapLocks), for: .touchUpInside) } @@ -55,6 +56,10 @@ final class AssetDetailsViewController: UIViewController, ViewHolder { presenter.handleBuy() } + @objc private func didTapSwapButton() { + presenter.handleSwap() + } + @objc private func didTapLocks() { presenter.handleLocks() } @@ -81,6 +86,7 @@ extension AssetDetailsViewController: AssetDetailsViewProtocol { func didReceive(availableOperations: AssetDetailsOperation) { rootView.sendButton.isEnabled = availableOperations.contains(.send) rootView.receiveButton.isEnabled = availableOperations.contains(.receive) + rootView.swapButton.isEnabled = availableOperations.contains(.swap) rootView.buyButton.isEnabled = availableOperations.contains(.buy) } } diff --git a/novawallet/Modules/AssetDetails/AssetDetailsViewFactory.swift b/novawallet/Modules/AssetDetails/AssetDetailsViewFactory.swift index 439688da81..7d58826751 100644 --- a/novawallet/Modules/AssetDetails/AssetDetailsViewFactory.swift +++ b/novawallet/Modules/AssetDetails/AssetDetailsViewFactory.swift @@ -2,14 +2,25 @@ import Foundation import SoraFoundation struct AssetDetailsViewFactory { - static func createView(chain: ChainModel, asset: AssetModel) -> AssetDetailsViewProtocol? { + static func createView( + chain: ChainModel, + asset: AssetModel, + operationState: AssetOperationState + ) -> AssetDetailsViewProtocol? { guard let currencyManager = CurrencyManager.shared else { return nil } guard let selectedAccount = SelectedWalletSettings.shared.value else { return nil } + let chainAsset = ChainAsset(chain: chain, asset: asset) + + let assetConversionAggregator = AssetConversionAggregationFactory( + chainRegistry: ChainRegistryFacade.sharedRegistry, + operationQueue: OperationManagerFacade.sharedDefaultQueue + ) + let interactor = AssetDetailsInteractor( selectedMetaAccount: selectedAccount, chainAsset: chainAsset, @@ -17,9 +28,12 @@ struct AssetDetailsViewFactory { walletLocalSubscriptionFactory: WalletLocalSubscriptionFactory.shared, priceLocalSubscriptionFactory: PriceProviderFactory.shared, externalBalancesSubscriptionFactory: ExternalBalanceLocalSubscriptionFactory.shared, + assetConvertionAggregator: assetConversionAggregator, + operationQueue: OperationManagerFacade.sharedDefaultQueue, currencyManager: currencyManager ) - let wireframe = AssetDetailsWireframe() + + let wireframe = AssetDetailsWireframe(operationState: operationState) let priceAssetInfoFactory = PriceAssetInfoFactory(currencyManager: currencyManager) let viewModelFactory = AssetDetailsViewModelFactory( diff --git a/novawallet/Modules/AssetDetails/AssetDetailsWireframe.swift b/novawallet/Modules/AssetDetails/AssetDetailsWireframe.swift index 706bfcc5c8..3f9fe138a4 100644 --- a/novawallet/Modules/AssetDetails/AssetDetailsWireframe.swift +++ b/novawallet/Modules/AssetDetails/AssetDetailsWireframe.swift @@ -4,6 +4,12 @@ import SoraUI import SoraFoundation final class AssetDetailsWireframe: AssetDetailsWireframeProtocol { + let operationState: AssetOperationState + + init(operationState: AssetOperationState) { + self.operationState = operationState + } + func showPurchaseTokens( from view: AssetDetailsViewProtocol?, action: PurchaseAction, @@ -93,6 +99,20 @@ final class AssetDetailsWireframe: AssetDetailsWireframeProtocol { present(confirmationView.controller, from: view) } + func showSwaps(from view: AssetDetailsViewProtocol?, chainAsset: ChainAsset) { + guard let swapsView = SwapSetupViewFactory.createView( + assetListObservable: operationState.assetListObservable, + payChainAsset: chainAsset, + swapCompletionClosure: operationState.swapCompletionClosure + ) else { + return + } + + let navigationController = ImportantFlowViewFactory.createNavigation(from: swapsView.controller) + + view?.controller.present(navigationController, animated: true) + } + private func present(_ viewController: UIViewController, from view: AssetDetailsViewProtocol?) { guard let navigationController = view?.controller.navigationController else { return diff --git a/novawallet/Modules/AssetDetails/Container/AssetDetailsContainerProtocols.swift b/novawallet/Modules/AssetDetails/Container/AssetDetailsContainerProtocols.swift index 10abb8643c..d9190de52b 100644 --- a/novawallet/Modules/AssetDetails/Container/AssetDetailsContainerProtocols.swift +++ b/novawallet/Modules/AssetDetails/Container/AssetDetailsContainerProtocols.swift @@ -1,5 +1,9 @@ protocol AssetDetailsContainerViewFactoryProtocol { - static func createView(chain: ChainModel, asset: AssetModel) -> AssetDetailsContainerViewProtocol? + static func createView( + chain: ChainModel, + asset: AssetModel, + operationState: AssetOperationState + ) -> AssetDetailsContainerViewProtocol? } protocol AssetDetailsContainerViewProtocol: ControllerBackedProtocol {} diff --git a/novawallet/Modules/AssetDetails/Container/AssetDetailsContainerViewFactory.swift b/novawallet/Modules/AssetDetails/Container/AssetDetailsContainerViewFactory.swift index 357341c922..e31993bc87 100644 --- a/novawallet/Modules/AssetDetails/Container/AssetDetailsContainerViewFactory.swift +++ b/novawallet/Modules/AssetDetails/Container/AssetDetailsContainerViewFactory.swift @@ -1,14 +1,20 @@ import SoraFoundation final class AssetDetailsContainerViewFactory: AssetDetailsContainerViewFactoryProtocol { - static func createView(chain: ChainModel, asset: AssetModel) -> AssetDetailsContainerViewProtocol? { + static func createView( + chain: ChainModel, + asset: AssetModel, + operationState: AssetOperationState + ) -> AssetDetailsContainerViewProtocol? { guard let accountView = AssetDetailsViewFactory.createView( chain: chain, - asset: asset + asset: asset, + operationState: operationState ), let historyView = TransactionHistoryViewFactory.createView( - chainAsset: .init(chain: chain, asset: asset) + chainAsset: .init(chain: chain, asset: asset), + operationState: operationState ) else { return nil } diff --git a/novawallet/Modules/AssetDetails/Model/AssetDetailsOperation.swift b/novawallet/Modules/AssetDetails/Model/AssetDetailsOperation.swift index 68abb21635..49bc2c12b4 100644 --- a/novawallet/Modules/AssetDetails/Model/AssetDetailsOperation.swift +++ b/novawallet/Modules/AssetDetails/Model/AssetDetailsOperation.swift @@ -6,4 +6,5 @@ struct AssetDetailsOperation: OptionSet { static let send = AssetDetailsOperation(rawValue: 1 << 0) static let receive = AssetDetailsOperation(rawValue: 1 << 1) static let buy = AssetDetailsOperation(rawValue: 1 << 2) + static let swap = AssetDetailsOperation(rawValue: 1 << 3) } diff --git a/novawallet/Modules/AssetDetails/Model/AssetOperationState.swift b/novawallet/Modules/AssetDetails/Model/AssetOperationState.swift new file mode 100644 index 0000000000..5e2574c787 --- /dev/null +++ b/novawallet/Modules/AssetDetails/Model/AssetOperationState.swift @@ -0,0 +1,12 @@ +final class AssetOperationState { + let assetListObservable: AssetListModelObservable + let swapCompletionClosure: SwapCompletionClosure? + + init( + assetListObservable: AssetListModelObservable, + swapCompletionClosure: SwapCompletionClosure? + ) { + self.assetListObservable = assetListObservable + self.swapCompletionClosure = swapCompletionClosure + } +} diff --git a/novawallet/Modules/AssetDetails/View/AssetDetailsViewLayout.swift b/novawallet/Modules/AssetDetails/View/AssetDetailsViewLayout.swift index e08cbdded4..6e0c4e899d 100644 --- a/novawallet/Modules/AssetDetails/View/AssetDetailsViewLayout.swift +++ b/novawallet/Modules/AssetDetails/View/AssetDetailsViewLayout.swift @@ -56,10 +56,11 @@ final class AssetDetailsViewLayout: UIView { let sendButton: RoundedButton = createOperationButton(icon: R.image.iconSend()) let receiveButton: RoundedButton = createOperationButton(icon: R.image.iconReceive()) let buyButton: RoundedButton = createOperationButton(icon: R.image.iconBuy()) + let swapButton = createOperationButton(icon: R.image.iconActionChange()) private lazy var buttonsRow = PayButtonsRow( frame: .zero, - views: [sendButton, receiveButton, buyButton] + views: [sendButton, receiveButton, swapButton, buyButton] ) private let balanceTableView: StackTableView = .create { @@ -181,6 +182,11 @@ final class AssetDetailsViewLayout: UIView { ) receiveButton.invalidateLayout() + swapButton.imageWithTitleView?.title = R.string.localizable.commonSwapAction( + preferredLanguages: languages + ) + swapButton.invalidateLayout() + buyButton.imageWithTitleView?.title = R.string.localizable.walletAssetBuy( preferredLanguages: languages ) diff --git a/novawallet/Modules/AssetList/AssetListPresenter.swift b/novawallet/Modules/AssetList/AssetListPresenter.swift index 8e7e55ec6c..9adba72652 100644 --- a/novawallet/Modules/AssetList/AssetListPresenter.swift +++ b/novawallet/Modules/AssetList/AssetListPresenter.swift @@ -55,12 +55,17 @@ final class AssetListPresenter { guard case let .success(priceMapping) = model.priceResult, !model.balanceResults.isEmpty else { let viewModel = viewModelFactory.createHeaderViewModel( - from: name, - walletIdenticon: walletIdenticon, - walletType: walletType, - prices: nil, - locks: nil, - walletConnectSessionsCount: walletConnectSessionsCount, + params: .init( + title: name, + wallet: .init( + walletIdenticon: walletIdenticon, + walletType: walletType, + walletConnectSessionsCount: walletConnectSessionsCount + ), + prices: nil, + locks: nil, + hasSwaps: model.hasSwaps() + ), locale: selectedLocale ) @@ -140,12 +145,17 @@ final class AssetListPresenter { let totalLocks = createHeaderLockState(from: priceMapping, externalBalances: externalBalances) let viewModel = viewModelFactory.createHeaderViewModel( - from: name, - walletIdenticon: walletIdenticon, - walletType: walletType, - prices: totalValue, - locks: totalLocks, - walletConnectSessionsCount: walletConnectSessionsCount, + params: .init( + title: name, + wallet: .init( + walletIdenticon: walletIdenticon, + walletType: walletType, + walletConnectSessionsCount: walletConnectSessionsCount + ), + prices: totalValue, + locks: totalLocks, + hasSwaps: model.hasSwaps() + ), locale: selectedLocale ) @@ -440,6 +450,10 @@ extension AssetListPresenter: AssetListPresenterProtocol { wireframe.showBuyTokens(from: view) } + func swap() { + wireframe.showSwapTokens(from: view) + } + func presentWalletConnect() { if walletConnectSessionsCount > 0 { wireframe.showWalletConnect(from: view) diff --git a/novawallet/Modules/AssetList/AssetListProtocols.swift b/novawallet/Modules/AssetList/AssetListProtocols.swift index 1d53253b78..d8b0c46b95 100644 --- a/novawallet/Modules/AssetList/AssetListProtocols.swift +++ b/novawallet/Modules/AssetList/AssetListProtocols.swift @@ -25,6 +25,7 @@ protocol AssetListPresenterProtocol: AnyObject { func send() func receive() func buy() + func swap() func presentWalletConnect() func selectPromotion() func closePromotion() @@ -80,9 +81,12 @@ protocol AssetListWireframeProtocol: AnyObject, WalletSwitchPresentable, AlertPr func showBuyTokens(from view: AssetListViewProtocol?) + func showSwapTokens(from view: AssetListViewProtocol?) + func showStaking(from view: AssetListViewProtocol?) } typealias WalletConnectSessionsError = WalletConnectSessionsInteractorError typealias TransferCompletionClosure = (ChainAsset) -> Void typealias BuyTokensClosure = () -> Void +typealias SwapCompletionClosure = (ChainAsset) -> Void diff --git a/novawallet/Modules/AssetList/AssetListViewController.swift b/novawallet/Modules/AssetList/AssetListViewController.swift index f40a0277a1..bf2802b4dd 100644 --- a/novawallet/Modules/AssetList/AssetListViewController.swift +++ b/novawallet/Modules/AssetList/AssetListViewController.swift @@ -124,6 +124,10 @@ final class AssetListViewController: UIViewController, ViewHolder { @objc private func actionBuy() { presenter.buy() } + + @objc private func actionSwap() { + presenter.swap() + } } extension AssetListViewController: UICollectionViewDelegateFlowLayout { @@ -274,6 +278,11 @@ extension AssetListViewController: UICollectionViewDataSource { action: #selector(actionBuy), for: .touchUpInside ) + totalBalanceCell.swapButton.addTarget( + self, + action: #selector(actionSwap), + for: .touchUpInside + ) if let viewModel = headerViewModel { totalBalanceCell.bind(viewModel: viewModel) } diff --git a/novawallet/Modules/AssetList/AssetListWireframe.swift b/novawallet/Modules/AssetList/AssetListWireframe.swift index 553af48c50..97f31f2de2 100644 --- a/novawallet/Modules/AssetList/AssetListWireframe.swift +++ b/novawallet/Modules/AssetList/AssetListWireframe.swift @@ -15,9 +15,20 @@ final class AssetListWireframe: AssetListWireframeProtocol { } func showAssetDetails(from view: AssetListViewProtocol?, chain: ChainModel, asset: AssetModel) { + let swapCompletionClosure: (ChainAsset) -> Void = { [weak self, weak view] chainAsset in + view?.controller.navigationController?.popToRootViewController(animated: false) + self?.showAssetDetails(from: view, chain: chainAsset.chain, asset: chainAsset.asset) + } + + let operationState = AssetOperationState( + assetListObservable: assetListModelObservable, + swapCompletionClosure: swapCompletionClosure + ) + guard let assetDetailsView = AssetDetailsContainerViewFactory.createView( chain: chain, - asset: asset + asset: asset, + operationState: operationState ), let navigationController = view?.controller.navigationController else { return @@ -28,22 +39,6 @@ final class AssetListWireframe: AssetListWireframeProtocol { ) } - func showHistory(from view: AssetListViewProtocol?, chain: ChainModel, asset: AssetModel) { - guard let history = TransactionHistoryViewFactory.createView( - chainAsset: .init(chain: chain, asset: asset) - ) else { - return - } - guard let navigationController = view?.controller.navigationController else { - return - } - - navigationController.pushViewController( - history.controller, - animated: true - ) - } - func showAssetsSettings(from view: AssetListViewProtocol?) { guard let assetsManageView = AssetsSettingsViewFactory.createView() else { return @@ -127,6 +122,32 @@ final class AssetListWireframe: AssetListWireframeProtocol { view?.controller.present(navigationController, animated: true, completion: nil) } + func showSwapTokens(from view: AssetListViewProtocol?) { + let completionClosure: (ChainAsset) -> Void = { [weak self] chainAsset in + self?.showAssetDetails(from: view, chain: chainAsset.chain, asset: chainAsset.asset) + } + let selectClosure: (ChainAsset) -> Void = { [weak self] chainAsset in + self?.showSwapTokens( + from: view, + payAsset: chainAsset, + swapCompletionClosure: completionClosure + ) + } + guard let swapDirectionsView = SwapAssetsOperationViewFactory.createSelectPayTokenView( + for: assetListModelObservable, + selectClosureStrategy: .callbackAfterDismissal, + selectClosure: selectClosure + ) else { + return + } + + let navigationController = NovaNavigationController( + rootViewController: swapDirectionsView.controller + ) + + view?.controller.present(navigationController, animated: true, completion: nil) + } + func showNfts(from view: AssetListViewProtocol?) { guard let nftListView = NftListViewFactory.createView() else { return @@ -167,4 +188,22 @@ final class AssetListWireframe: AssetListWireframeProtocol { tabBarController.selectedIndex = MainTabBarIndex.staking } + + private func showSwapTokens( + from view: AssetListViewProtocol?, + payAsset: ChainAsset, + swapCompletionClosure: SwapCompletionClosure? + ) { + guard let swapTokensView = SwapSetupViewFactory.createView( + assetListObservable: assetListModelObservable, + payChainAsset: payAsset, + swapCompletionClosure: swapCompletionClosure + ) else { + return + } + + let navigationController = ImportantFlowViewFactory.createNavigation(from: swapTokensView.controller) + + view?.controller.present(navigationController, animated: true, completion: nil) + } } diff --git a/novawallet/Modules/AssetList/Base/AssetListAssetsViewModelFactory.swift b/novawallet/Modules/AssetList/Base/AssetListAssetsViewModelFactory.swift index da8d38da6a..b5a69842b9 100644 --- a/novawallet/Modules/AssetList/Base/AssetListAssetsViewModelFactory.swift +++ b/novawallet/Modules/AssetList/Base/AssetListAssetsViewModelFactory.swift @@ -112,6 +112,13 @@ class AssetListAssetViewModelFactory { return .loading } } + + func formatPrice(amount: Decimal, priceData: PriceData?, locale: Locale) -> String { + let currencyId = priceData?.currencyId ?? currencyManager.selectedCurrency.id + let assetDisplayInfo = priceAssetInfoFactory.createAssetBalanceDisplayInfo(from: currencyId) + let priceFormatter = assetFormatterFactory.createAssetPriceFormatter(for: assetDisplayInfo) + return priceFormatter.value(for: locale).stringFromDecimal(amount) ?? "" + } } extension AssetListAssetViewModelFactory: AssetListAssetViewModelFactoryProtocol { @@ -149,13 +156,6 @@ extension AssetListAssetViewModelFactory: AssetListAssetViewModelFactoryProtocol ) } - func formatPrice(amount: Decimal, priceData: PriceData?, locale: Locale) -> String { - let currencyId = priceData?.currencyId ?? currencyManager.selectedCurrency.id - let assetDisplayInfo = priceAssetInfoFactory.createAssetBalanceDisplayInfo(from: currencyId) - let priceFormatter = assetFormatterFactory.createAssetPriceFormatter(for: assetDisplayInfo) - return priceFormatter.value(for: locale).stringFromDecimal(amount) ?? "" - } - func createAssetViewModel( chainId: ChainModel.Id, assetAccountInfo: AssetListAssetAccountInfo, diff --git a/novawallet/Modules/AssetList/Models/AssetListAssetModel.swift b/novawallet/Modules/AssetList/Models/AssetListAssetModel.swift index d4c42baca7..65d93d0508 100644 --- a/novawallet/Modules/AssetList/Models/AssetListAssetModel.swift +++ b/novawallet/Modules/AssetList/Models/AssetListAssetModel.swift @@ -12,14 +12,31 @@ struct AssetListAssetModel: Identifiable { let externalBalancesResult: Result? let externalBalancesValue: Decimal? - var totalAmount: BigUInt? { + let totalAmountDecimal: Decimal? + let totalAmount: BigUInt? + + init( + assetModel: AssetModel, + balanceResult: Result?, + balanceValue: Decimal?, + externalBalancesResult: Result?, + externalBalancesValue: Decimal? + ) { + self.assetModel = assetModel + self.balanceResult = balanceResult + self.balanceValue = balanceValue + self.externalBalancesResult = externalBalancesResult + self.externalBalancesValue = externalBalancesValue + let maybeBalanceAmount = try? balanceResult?.get() let maybeExternalBalances = try? externalBalancesResult?.get() if let balanceAmount = maybeBalanceAmount, let externalBalancesAmount = maybeExternalBalances { - return balanceAmount + externalBalancesAmount + totalAmount = balanceAmount + externalBalancesAmount } else { - return maybeBalanceAmount ?? maybeExternalBalances + totalAmount = maybeBalanceAmount ?? maybeExternalBalances } + + totalAmountDecimal = totalAmount?.decimal(precision: assetModel.precision) } var totalValue: Decimal? { diff --git a/novawallet/Modules/AssetList/Models/AssetListBuilderResult.swift b/novawallet/Modules/AssetList/Models/AssetListBuilderResult.swift index 992c98ca31..6d9c62a161 100644 --- a/novawallet/Modules/AssetList/Models/AssetListBuilderResult.swift +++ b/novawallet/Modules/AssetList/Models/AssetListBuilderResult.swift @@ -48,6 +48,22 @@ struct AssetListBuilderResult { locksResult: locksResult ) } + + func hasSwaps() -> Bool { + allChains.values.contains { chain in + guard chain.hasSwaps else { + return false + } + return chain.assets.contains { asset in + let chainAssetId = ChainAssetId(chainId: chain.chainId, assetId: asset.assetId) + if case .success = balanceResults[chainAssetId] { + return true + } else { + return false + } + } + } + } } enum ChangeKind { diff --git a/novawallet/Modules/AssetList/Models/AssetListGroupModel.swift b/novawallet/Modules/AssetList/Models/AssetListGroupModel.swift index 599c023c72..ed2433fcb4 100644 --- a/novawallet/Modules/AssetList/Models/AssetListGroupModel.swift +++ b/novawallet/Modules/AssetList/Models/AssetListGroupModel.swift @@ -1,14 +1,17 @@ import Foundation import RobinHood +import BigInt struct AssetListGroupModel: Identifiable { var identifier: String { chain.chainId } let chain: ChainModel let chainValue: Decimal + let chainAmount: Decimal - init(chain: ChainModel, chainValue: Decimal) { + init(chain: ChainModel, chainValue: Decimal, chainAmount: Decimal) { self.chain = chain self.chainValue = chainValue + self.chainAmount = chainAmount } } diff --git a/novawallet/Modules/AssetList/Models/AssetListGroupModelCompator.swift b/novawallet/Modules/AssetList/Models/AssetListGroupModelCompator.swift new file mode 100644 index 0000000000..c97e03d42c --- /dev/null +++ b/novawallet/Modules/AssetList/Models/AssetListGroupModelCompator.swift @@ -0,0 +1,26 @@ +enum AssetListGroupModelComparator { + static var byValue: (AssetListGroupModel, AssetListGroupModel) -> Bool? = { + compare(model1: $0, model2: $1, by: \.chainValue, zeroValue: 0) + } + + static var byTotalAmount: (AssetListGroupModel, AssetListGroupModel) -> Bool? = { + compare(model1: $0, model2: $1, by: \.chainAmount, zeroValue: 0) + } + + static func compare( + model1: AssetListGroupModel, + model2: AssetListGroupModel, + by keypath: KeyPath, + zeroValue: T + ) -> Bool? where T: Comparable { + if model1[keyPath: keypath] > zeroValue, model2[keyPath: keypath] > zeroValue { + return model1[keyPath: keypath] > model2[keyPath: keypath] + } else if model1[keyPath: keypath] > zeroValue { + return true + } else if model2[keyPath: keypath] > zeroValue { + return false + } else { + return nil + } + } +} diff --git a/novawallet/Modules/AssetList/Models/AssetListModelHelpers.swift b/novawallet/Modules/AssetList/Models/AssetListModelHelpers.swift index 39d1f4d811..2d8d8961c1 100644 --- a/novawallet/Modules/AssetList/Models/AssetListModelHelpers.swift +++ b/novawallet/Modules/AssetList/Models/AssetListModelHelpers.swift @@ -19,23 +19,28 @@ enum AssetListModelHelpers { from chain: ChainModel, assets: [AssetListAssetModel] ) -> AssetListGroupModel { - let value: Decimal = assets.reduce(0) { result, asset in - result + (asset.totalValue ?? 0) + let amountValue: AmountPair = assets.reduce(.init(amount: 0, value: 0)) { result, asset in + .init( + amount: result.amount + asset.totalAmount.decimalOrZero(precision: asset.assetModel.precision), + value: result.value + (asset.totalValue ?? 0) + ) } - return AssetListGroupModel(chain: chain, chainValue: value) + return AssetListGroupModel( + chain: chain, + chainValue: amountValue.value, + chainAmount: amountValue.amount + ) } static func createGroupsDiffCalculator( from groups: [AssetListGroupModel] ) -> ListDifferenceCalculator { let sortingBlock: (AssetListGroupModel, AssetListGroupModel) -> Bool = { model1, model2 in - if model1.chainValue > 0, model2.chainValue > 0 { - return model1.chainValue > model2.chainValue - } else if model1.chainValue > 0 { - return true - } else if model2.chainValue > 0 { - return false + if let result = AssetListGroupModelComparator.byValue(model1, model2) { + return result + } else if let result = AssetListGroupModelComparator.byTotalAmount(model1, model2) { + return result } else { return ChainModelCompator.defaultComparator(chain1: model1.chain, chain2: model2.chain) } @@ -50,8 +55,8 @@ enum AssetListModelHelpers { from assets: [AssetListAssetModel] ) -> ListDifferenceCalculator { let sortingBlock: (AssetListAssetModel, AssetListAssetModel) -> Bool = { model1, model2 in - let balance1 = (try? model1.balanceResult?.get()) ?? 0 - let balance2 = (try? model2.balanceResult?.get()) ?? 0 + let balance1 = model1.totalAmountDecimal ?? 0 + let balance2 = model2.totalAmountDecimal ?? 0 let assetValue1 = model1.totalValue ?? 0 let assetValue2 = model2.totalValue ?? 0 @@ -63,7 +68,7 @@ enum AssetListModelHelpers { } else if assetValue2 > 0 { return false } else if balance1 > 0, balance2 > 0 { - return model1.assetModel.assetId < model2.assetModel.assetId + return balance1 > balance2 } else if balance1 > 0 { return true } else if balance2 > 0 { diff --git a/novawallet/Modules/AssetList/View/AssetListTotalBalanceCell.swift b/novawallet/Modules/AssetList/View/AssetListTotalBalanceCell.swift index 8a8300567f..c6cc91cd3d 100644 --- a/novawallet/Modules/AssetList/View/AssetListTotalBalanceCell.swift +++ b/novawallet/Modules/AssetList/View/AssetListTotalBalanceCell.swift @@ -1,5 +1,6 @@ import UIKit import SoraUI +import Kingfisher final class AssetListTotalBalanceCell: UICollectionViewCell { private enum Constants { @@ -8,6 +9,7 @@ final class AssetListTotalBalanceCell: UICollectionViewCell { static let cardMotionAngle: CGFloat = 2 * CGFloat.pi / 180 static let elementMovingMotion: CGFloat = 5 static let locksContentInsets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) + static let infoIconSize = CGSize(width: 12, height: 12) } let backgroundBlurView = GladingCardView() @@ -35,7 +37,7 @@ final class AssetListTotalBalanceCell: UICollectionViewCell { contentView.spacing = 4 contentView.detailsView.spacing = 4 contentView.detailsView.mode = .detailsIcon - contentView.detailsView.imageView.image = R.image.iconInfoFilled()?.tinted(with: R.color.colorIconChip()!) + contentView.detailsView.imageView.image = R.image.iconInfoFilled()?.kf.resize(to: Constants.infoIconSize) } $0.isHidden = true @@ -51,6 +53,12 @@ final class AssetListTotalBalanceCell: UICollectionViewCell { R.string.localizable.walletAssetReceive(preferredLanguages: locale.rLanguages), icon: R.image.iconReceive() ) + lazy var swapButton = createActionButton( + title: R.string.localizable.commonSwapAction( + preferredLanguages: locale.rLanguages + ), + icon: R.image.iconActionChange() + ) lazy var buyButton = createActionButton( title: R.string.localizable.walletAssetBuy( preferredLanguages: locale.rLanguages @@ -63,6 +71,7 @@ final class AssetListTotalBalanceCell: UICollectionViewCell { [ sendButton, receiveButton, + swapButton, buyButton ] ) @@ -144,6 +153,8 @@ final class AssetListTotalBalanceCell: UICollectionViewCell { setupStateWithoutLocks() startLoadingIfNeeded() } + + swapButton.isEnabled = viewModel.hasSwaps } private func totalAmountString(from model: AssetListTotalAmountViewModel) -> NSAttributedString { @@ -206,6 +217,9 @@ final class AssetListTotalBalanceCell: UICollectionViewCell { buyButton.imageWithTitleView?.title = R.string.localizable.walletAssetBuy( preferredLanguages: locale.rLanguages ) + swapButton.imageWithTitleView?.title = R.string.localizable.commonSwapAction( + preferredLanguages: locale.rLanguages + ) } private func setupLayout() { diff --git a/novawallet/Modules/AssetList/ViewModel/AssetListViewModel.swift b/novawallet/Modules/AssetList/ViewModel/AssetListViewModel.swift index 7c75c262f7..57fc4fb880 100644 --- a/novawallet/Modules/AssetList/ViewModel/AssetListViewModel.swift +++ b/novawallet/Modules/AssetList/ViewModel/AssetListViewModel.swift @@ -49,6 +49,7 @@ struct AssetListHeaderViewModel { let amount: LoadableViewModelState let locksAmount: String? let walletSwitch: WalletSwitchViewModel + let hasSwaps: Bool } struct AssetListTotalAmountViewModel { diff --git a/novawallet/Modules/AssetList/ViewModel/AssetListViewModelFactory.swift b/novawallet/Modules/AssetList/ViewModel/AssetListViewModelFactory.swift index 0b31d7eef7..15a9da9db6 100644 --- a/novawallet/Modules/AssetList/ViewModel/AssetListViewModelFactory.swift +++ b/novawallet/Modules/AssetList/ViewModel/AssetListViewModelFactory.swift @@ -9,16 +9,22 @@ struct AssetListAssetAccountPrice { let price: PriceData } +struct AssetListHeaderParams { + struct Wallet { + let walletIdenticon: Data? + let walletType: MetaAccountModelType + let walletConnectSessionsCount: Int + } + + let title: String + let wallet: Wallet + let prices: LoadableViewModelState<[AssetListAssetAccountPrice]>? + let locks: [AssetListAssetAccountPrice]? + let hasSwaps: Bool +} + protocol AssetListViewModelFactoryProtocol: AssetListAssetViewModelFactoryProtocol { - func createHeaderViewModel( - from title: String, - walletIdenticon: Data?, - walletType: MetaAccountModelType, - prices: LoadableViewModelState<[AssetListAssetAccountPrice]>?, - locks: [AssetListAssetAccountPrice]?, - walletConnectSessionsCount: Int, - locale: Locale - ) -> AssetListHeaderViewModel + func createHeaderViewModel(params: AssetListHeaderParams, locale: Locale) -> AssetListHeaderViewModel func createNftsViewModel(from nfts: [NftModel], locale: Locale) -> AssetListNftsViewModel } @@ -81,39 +87,36 @@ final class AssetListViewModelFactory: AssetListAssetViewModelFactory { } extension AssetListViewModelFactory: AssetListViewModelFactoryProtocol { - func createHeaderViewModel( - from title: String, - walletIdenticon: Data?, - walletType: MetaAccountModelType, - prices: LoadableViewModelState<[AssetListAssetAccountPrice]>?, - locks: [AssetListAssetAccountPrice]?, - walletConnectSessionsCount: Int, - locale: Locale - ) -> AssetListHeaderViewModel { - let icon = walletIdenticon.flatMap { try? iconGenerator.generateFromAccountId($0) } + func createHeaderViewModel(params: AssetListHeaderParams, locale: Locale) -> AssetListHeaderViewModel { + let icon = params.wallet.walletIdenticon.flatMap { try? iconGenerator.generateFromAccountId($0) } let walletSwitch = WalletSwitchViewModel( - type: WalletsListSectionViewModel.SectionType(walletType: walletType), + type: WalletsListSectionViewModel.SectionType(walletType: params.wallet.walletType), iconViewModel: icon.map { DrawableIconViewModel(icon: $0) } ) + + let walletConnectSessionsCount = params.wallet.walletConnectSessionsCount let formattedWalletConnectSessionsCount = walletConnectSessionsCount > 0 ? - quantityFormatter.value(for: locale).string(from: NSNumber(value: walletConnectSessionsCount)) : nil + quantityFormatter.value(for: locale).string(from: NSNumber(value: walletConnectSessionsCount)) : + nil - if let prices = prices { + if let prices = params.prices { let totalPrice = createTotalPrice(from: prices, locale: locale) return AssetListHeaderViewModel( walletConnectSessionsCount: formattedWalletConnectSessionsCount, - title: title, + title: params.title, amount: totalPrice, - locksAmount: locks.map { formatTotalPrice(from: $0, locale: locale) }, - walletSwitch: walletSwitch + locksAmount: params.locks.map { formatTotalPrice(from: $0, locale: locale) }, + walletSwitch: walletSwitch, + hasSwaps: params.hasSwaps ) } else { return AssetListHeaderViewModel( walletConnectSessionsCount: formattedWalletConnectSessionsCount, - title: title, + title: params.title, amount: .loading, locksAmount: nil, - walletSwitch: walletSwitch + walletSwitch: walletSwitch, + hasSwaps: params.hasSwaps ) } } diff --git a/novawallet/Modules/AssetsSearch/AssetOperation/Buy/BuyAssetOperationWireframe.swift b/novawallet/Modules/AssetsSearch/AssetOperation/Buy/BuyAssetOperationWireframe.swift index 52e0234fd7..627e8a78d3 100644 --- a/novawallet/Modules/AssetsSearch/AssetOperation/Buy/BuyAssetOperationWireframe.swift +++ b/novawallet/Modules/AssetsSearch/AssetOperation/Buy/BuyAssetOperationWireframe.swift @@ -1,12 +1,13 @@ import UIKit import SoraUI -protocol BuyAssetOperationWireframeProtocol: AssetsSearchWireframeProtocol, MessageSheetPresentable, PurchasePresentable, AlertPresentable {} +protocol BuyAssetOperationWireframeProtocol: AssetsSearchWireframeProtocol, MessageSheetPresentable, + PurchasePresentable, AlertPresentable {} final class BuyAssetOperationWireframe: BuyAssetOperationWireframeProtocol {} extension BuyAssetOperationWireframe: AssetsSearchWireframeProtocol { - func close(view: AssetsSearchViewProtocol?) { - view?.controller.presentingViewController?.dismiss(animated: true) + func close(view: AssetsSearchViewProtocol?, completion: (() -> Void)?) { + view?.controller.presentingViewController?.dismiss(animated: true, completion: completion) } } diff --git a/novawallet/Modules/AssetsSearch/AssetOperation/Receive/ReceiveAssetOperationWireframe.swift b/novawallet/Modules/AssetsSearch/AssetOperation/Receive/ReceiveAssetOperationWireframe.swift index 54f77e30de..9118657c8c 100644 --- a/novawallet/Modules/AssetsSearch/AssetOperation/Receive/ReceiveAssetOperationWireframe.swift +++ b/novawallet/Modules/AssetsSearch/AssetOperation/Receive/ReceiveAssetOperationWireframe.swift @@ -24,7 +24,7 @@ final class ReceiveAssetOperationWireframe: ReceiveAssetOperationWireframeProtoc } extension ReceiveAssetOperationWireframe: AssetsSearchWireframeProtocol { - func close(view: AssetsSearchViewProtocol?) { - view?.controller.presentingViewController?.dismiss(animated: true) + func close(view: AssetsSearchViewProtocol?, completion: (() -> Void)?) { + view?.controller.presentingViewController?.dismiss(animated: true, completion: completion) } } diff --git a/novawallet/Modules/AssetsSearch/AssetOperation/Send/SendAssetOperationWireframe.swift b/novawallet/Modules/AssetsSearch/AssetOperation/Send/SendAssetOperationWireframe.swift index e9271dadb6..16ba8bf2ce 100644 --- a/novawallet/Modules/AssetsSearch/AssetOperation/Send/SendAssetOperationWireframe.swift +++ b/novawallet/Modules/AssetsSearch/AssetOperation/Send/SendAssetOperationWireframe.swift @@ -34,7 +34,7 @@ final class SendAssetOperationWireframe: SendAssetOperationWireframeProtocol { } extension SendAssetOperationWireframe: AssetsSearchWireframeProtocol { - func close(view: AssetsSearchViewProtocol?) { - view?.controller.presentingViewController?.dismiss(animated: true) + func close(view: AssetsSearchViewProtocol?, completion: (() -> Void)?) { + view?.controller.presentingViewController?.dismiss(animated: true, completion: completion) } } diff --git a/novawallet/Modules/AssetsSearch/AssetSearch/AssetsSearchProtocols.swift b/novawallet/Modules/AssetsSearch/AssetSearch/AssetsSearchProtocols.swift index 6fda7b652c..aedbbbd565 100644 --- a/novawallet/Modules/AssetsSearch/AssetSearch/AssetsSearchProtocols.swift +++ b/novawallet/Modules/AssetsSearch/AssetSearch/AssetsSearchProtocols.swift @@ -19,7 +19,13 @@ protocol AssetsSearchInteractorOutputProtocol: AnyObject { } protocol AssetsSearchWireframeProtocol: AnyObject { - func close(view: AssetsSearchViewProtocol?) + func close(view: AssetsSearchViewProtocol?, completion: (() -> Void)?) +} + +extension AssetsSearchWireframeProtocol { + func close(view: AssetsSearchViewProtocol?) { + close(view: view, completion: nil) + } } protocol AssetsSearchDelegate: AnyObject { diff --git a/novawallet/Modules/AssetsSearch/AssetSearch/AssetsSearchWireframe.swift b/novawallet/Modules/AssetsSearch/AssetSearch/AssetsSearchWireframe.swift index 4141750a3b..f9c4f6254d 100644 --- a/novawallet/Modules/AssetsSearch/AssetSearch/AssetsSearchWireframe.swift +++ b/novawallet/Modules/AssetsSearch/AssetSearch/AssetsSearchWireframe.swift @@ -1,7 +1,7 @@ import Foundation final class AssetsSearchWireframe: AssetsSearchWireframeProtocol { - func close(view: AssetsSearchViewProtocol?) { - view?.controller.presentingViewController?.dismiss(animated: true) + func close(view: AssetsSearchViewProtocol?, completion: (() -> Void)?) { + view?.controller.presentingViewController?.dismiss(animated: true, completion: completion) } } diff --git a/novawallet/Modules/AssetsSearch/Model/AssetSearchBuilder.swift b/novawallet/Modules/AssetsSearch/Model/AssetSearchBuilder.swift index 351c2f57b2..8bef5b7fd0 100644 --- a/novawallet/Modules/AssetsSearch/Model/AssetSearchBuilder.swift +++ b/novawallet/Modules/AssetsSearch/Model/AssetSearchBuilder.swift @@ -213,4 +213,14 @@ extension AssetSearchBuilder { self.rebuildResult(for: self.query, filter: self.filter) } } + + func reload() { + workingQueue.async { [weak self] in + guard let self = self else { + return + } + + self.rebuildResult(for: self.query, filter: self.filter) + } + } } diff --git a/novawallet/Modules/AssetsSearch/Swaps/SwapAssetOperationPresenter.swift b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetOperationPresenter.swift new file mode 100644 index 0000000000..f592751951 --- /dev/null +++ b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetOperationPresenter.swift @@ -0,0 +1,77 @@ +import Foundation +import BigInt +import RobinHood +import SoraFoundation + +final class SwapAssetsOperationPresenter: AssetsSearchPresenter { + var swapAssetsWireframe: SwapAssetsOperationWireframeProtocol? { + wireframe as? SwapAssetsOperationWireframeProtocol + } + + var swapAssetsView: SwapAssetsViewProtocol? { + view as? SwapAssetsViewProtocol + } + + let selectClosure: (ChainAsset) -> Void + + let selectClosureStrategy: SubmoduleNavigationStrategy + + let logger: LoggerProtocol + + init( + selectClosure: @escaping (ChainAsset) -> Void, + selectClosureStrategy: SubmoduleNavigationStrategy, + interactor: SwapAssetsOperationInteractorInputProtocol, + viewModelFactory: AssetListAssetViewModelFactoryProtocol, + localizationManager: LocalizationManagerProtocol, + wireframe: SwapAssetsOperationWireframeProtocol, + logger: Logger + ) { + self.selectClosure = selectClosure + self.selectClosureStrategy = selectClosureStrategy + self.logger = logger + + super.init( + delegate: nil, + interactor: interactor, + wireframe: wireframe, + viewModelFactory: viewModelFactory, + localizationManager: localizationManager + ) + } + + override func setup() { + interactor.setup() + swapAssetsView?.didStartLoading() + } + + override func selectAsset(for chainAssetId: ChainAssetId) { + guard let chainAsset = result?.state.chainAsset(for: chainAssetId) else { + return + } + + selectClosureStrategy.applyStrategy(for: { dismissalCallback in + self.wireframe.close(view: self.view, completion: dismissalCallback) + }, callback: { + self.selectClosure(chainAsset) + }) + } +} + +extension SwapAssetsOperationPresenter: SwapAssetsOperationPresenterProtocol { + func directionsLoaded() { + swapAssetsView?.didStopLoading() + } + + func didReceive(error: SwapAssetsOperationError) { + logger.error("Did receive error: \(error)") + + swapAssetsWireframe?.presentRequestStatus( + on: swapAssetsView, + locale: selectedLocale, + retryAction: { [weak self] in + self?.interactor.setup() + } + ) + } +} diff --git a/novawallet/Modules/AssetsSearch/Swaps/SwapAssetOperationWireframe.swift b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetOperationWireframe.swift new file mode 100644 index 0000000000..3f6ae7cfa6 --- /dev/null +++ b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetOperationWireframe.swift @@ -0,0 +1,10 @@ +import UIKit +import SoraUI + +final class SwapAssetsOperationWireframe: SwapAssetsOperationWireframeProtocol {} + +extension SwapAssetsOperationWireframe: AssetsSearchWireframeProtocol { + func close(view: AssetsSearchViewProtocol?, completion: (() -> Void)?) { + view?.controller.presentingViewController?.dismiss(animated: true, completion: completion) + } +} diff --git a/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationInteractor.swift b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationInteractor.swift new file mode 100644 index 0000000000..4a057ae9fd --- /dev/null +++ b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationInteractor.swift @@ -0,0 +1,190 @@ +import BigInt +import RobinHood + +final class SwapAssetsOperationInteractor: AnyCancellableCleaning { + weak var presenter: SwapAssetsOperationPresenterProtocol? + + let stateObservable: AssetListModelObservable + let logger: LoggerProtocol + let chainAsset: ChainAsset? + let assetConversionAggregation: AssetConversionAggregationFactoryProtocol + + private let operationQueue: OperationQueue + private var builder: AssetSearchBuilder? + private var directionsCall = CancellableCallStore() + private var availableDirections: [ChainAssetId: Set] = [:] + private var availableChains: Set = [] + + init( + stateObservable: AssetListModelObservable, + chainAsset: ChainAsset?, + assetConversionAggregation: AssetConversionAggregationFactoryProtocol, + operationQueue: OperationQueue, + logger: LoggerProtocol + ) { + self.stateObservable = stateObservable + self.logger = logger + self.chainAsset = chainAsset + self.assetConversionAggregation = assetConversionAggregation + self.operationQueue = operationQueue + } + + deinit { + directionsCall.cancel() + } + + private func reloadDirectionsIfNeeded() { + if let chainAsset = chainAsset { + guard !availableChains.contains(chainAsset.chain.chainId), chainAsset.chain.hasSwaps else { + presenter?.directionsLoaded() + return + } + + availableChains.insert(chainAsset.chain.chainId) + availableDirections = [:] + loadAssetDirections(for: chainAsset) + } else { + let allChains = stateObservable.state.value.allChains.values + + let chainsWithSwaps = allChains.filter(\.hasSwaps) + let chainsWithSwapsIds = Set(chainsWithSwaps.map(\.chainId)) + + if chainsWithSwapsIds != availableChains { + availableChains = chainsWithSwapsIds + availableDirections = [:] + + loadDirections(for: chainsWithSwaps) + } else { + presenter?.directionsLoaded() + } + } + } + + private func loadDirections(for chains: [ChainModel]) { + directionsCall.cancel() + + let wrappers = chains.map { assetConversionAggregation.createAvailableDirectionsWrapper(for: $0) } + + let dependencies = wrappers.flatMap(\.allOperations) + + let mergingOperation = ClosureOperation { + try wrappers.forEach { _ = try $0.targetOperation.extractNoCancellableResultData() } + } + + dependencies.forEach { + mergingOperation.addDependency($0) + } + + let commonWrapper = CompoundOperationWrapper(targetOperation: mergingOperation, dependencies: dependencies) + + wrappers.forEach { wrapper in + wrapper.targetOperation.completionBlock = { [weak self] in + DispatchQueue.main.async { + guard let self = self else { + return + } + + if case let .success(directions) = wrapper.targetOperation.result { + self.updateAvailableDirections(directions) + } + } + } + } + + executeCancellable( + wrapper: commonWrapper, + inOperationQueue: operationQueue, + backingCallIn: directionsCall, + runningCallbackIn: .main + ) { [weak self] result in + switch result { + case .success: + self?.presenter?.directionsLoaded() + case let .failure(error): + self?.handleDirectionsResponse(error: error) + } + } + } + + private func loadAssetDirections(for chainAsset: ChainAsset) { + directionsCall.cancel() + + let wrapper = assetConversionAggregation.createAvailableDirectionsWrapper(for: chainAsset) + + executeCancellable( + wrapper: wrapper, + inOperationQueue: operationQueue, + backingCallIn: directionsCall, + runningCallbackIn: .main + ) { [weak self] result in + switch result { + case let .success(directions): + self?.updateAvailableDirections([chainAsset.chainAssetId: directions]) + self?.presenter?.directionsLoaded() + case let .failure(error): + self?.handleDirectionsResponse(error: error) + } + } + } + + private func handleDirectionsResponse(error: Error) { + if let encodingError = error as? StorageKeyEncodingOperationError, encodingError == .invalidStoragePath { + // ignore not retryable errors + presenter?.directionsLoaded() + } else { + presenter?.didReceive(error: .directions(error)) + } + } + + private func updateAvailableDirections(_ newDirections: [ChainAssetId: Set]) { + availableDirections = newDirections.reduce(into: availableDirections) { accum, keyValue in + accum[keyValue.key] = keyValue.value + } + + builder?.reload() + } + + private func createBuilder() { + let searchQueue = OperationQueue() + searchQueue.maxConcurrentOperationCount = 1 + + let filter: ChainAssetsFilter = { [weak self] chainAsset in + guard let availableDirections = self?.availableDirections else { + return false + } + return availableDirections.contains(where: { $0.value.contains(chainAsset.chainAssetId) }) + } + + builder = .init( + filter: filter, + workingQueue: .main, + callbackQueue: .main, + callbackClosure: { [weak self] result in + self?.presenter?.didReceive(result: result) + }, + operationQueue: searchQueue, + logger: logger + ) + + builder?.apply(model: stateObservable.state.value) + + stateObservable.addObserver(with: self) { [weak self] _, newState in + guard let self = self else { + return + } + self.builder?.apply(model: newState.value) + self.reloadDirectionsIfNeeded() + } + } +} + +extension SwapAssetsOperationInteractor: SwapAssetsOperationInteractorInputProtocol { + func setup() { + createBuilder() + reloadDirectionsIfNeeded() + } + + func search(query: String) { + builder?.apply(query: query) + } +} diff --git a/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationProtocols.swift b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationProtocols.swift new file mode 100644 index 0000000000..2f139cff57 --- /dev/null +++ b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationProtocols.swift @@ -0,0 +1,18 @@ +protocol SwapAssetsOperationWireframeProtocol: AssetsSearchWireframeProtocol, ErrorPresentable, + AlertPresentable, CommonRetryable {} + +protocol SwapAssetsOperationPresenterProtocol: AssetsSearchInteractorOutputProtocol { + func directionsLoaded() + func didReceive(error: SwapAssetsOperationError) +} + +protocol SwapAssetsOperationInteractorInputProtocol: AssetsSearchInteractorInputProtocol {} + +enum SwapAssetsOperationError: Error { + case directions(Error) +} + +protocol SwapAssetsViewProtocol: AssetsSearchViewProtocol { + func didStartLoading() + func didStopLoading() +} diff --git a/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationViewController.swift b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationViewController.swift new file mode 100644 index 0000000000..2aba29fbf2 --- /dev/null +++ b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationViewController.swift @@ -0,0 +1,39 @@ +import UIKit + +final class SwapAssetsOperationViewController: AssetsSearchViewController { + private var isLoading: Bool = false + + var swapPresenter: SwapAssetsOperationPresenterProtocol? { + presenter as? SwapAssetsOperationPresenterProtocol + } + + var swapView: SwapAssetsOperationViewLayout? { + rootView as? SwapAssetsOperationViewLayout + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if isLoading { + swapView?.searchView.isUserInteractionEnabled = false + swapView?.activityIndicator.startAnimating() + } else { + swapView?.searchView.isUserInteractionEnabled = true + swapView?.activityIndicator.stopAnimating() + } + } +} + +extension SwapAssetsOperationViewController: SwapAssetsViewProtocol { + func didStartLoading() { + isLoading = true + swapView?.searchView.isUserInteractionEnabled = false + swapView?.activityIndicator.startAnimating() + } + + func didStopLoading() { + isLoading = false + swapView?.searchView.isUserInteractionEnabled = true + swapView?.activityIndicator.stopAnimating() + } +} diff --git a/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationViewFactory.swift b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationViewFactory.swift new file mode 100644 index 0000000000..e77a9992d0 --- /dev/null +++ b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationViewFactory.swift @@ -0,0 +1,127 @@ +import Foundation +import SoraFoundation + +enum SwapAssetsOperationViewFactory { + static func createSelectPayTokenView( + for stateObservable: AssetListModelObservable, + chainAsset: ChainAsset? = nil, + selectClosureStrategy: SubmoduleNavigationStrategy = .callbackBeforeDismissal, + selectClosure: @escaping (ChainAsset) -> Void + ) -> AssetsSearchViewProtocol? { + let title: LocalizableResource = .init { + R.string.localizable.swapsPayTokenSelectionTitle( + preferredLanguages: $0.rLanguages + ) + } + + return createView( + for: stateObservable, + chainAsset: chainAsset, + title: title, + selectClosureStrategy: selectClosureStrategy, + selectClosure: selectClosure + ) + } + + static func createSelectReceiveTokenView( + for stateObservable: AssetListModelObservable, + chainAsset: ChainAsset? = nil, + selectClosureStrategy: SubmoduleNavigationStrategy = .callbackBeforeDismissal, + selectClosure: @escaping (ChainAsset) -> Void + ) -> AssetsSearchViewProtocol? { + let title: LocalizableResource = .init { + R.string.localizable.swapsReceiveTokenSelectionTitle( + preferredLanguages: $0.rLanguages + ) + } + + return createView( + for: stateObservable, + chainAsset: chainAsset, + title: title, + selectClosureStrategy: selectClosureStrategy, + selectClosure: selectClosure + ) + } + + static func createView( + for stateObservable: AssetListModelObservable, + chainAsset: ChainAsset? = nil, + title: LocalizableResource, + selectClosureStrategy: SubmoduleNavigationStrategy, + selectClosure: @escaping (ChainAsset) -> Void + ) -> AssetsSearchViewProtocol? { + guard let currencyManager = CurrencyManager.shared else { + return nil + } + + let priceAssetInfoFactory = PriceAssetInfoFactory(currencyManager: currencyManager) + let viewModelFactory = AssetListAssetViewModelFactory( + priceAssetInfoFactory: priceAssetInfoFactory, + assetFormatterFactory: AssetBalanceFormatterFactory(), + percentFormatter: NumberFormatter.signedPercent.localizableResource(), + currencyManager: currencyManager + ) + + guard let presenter = createPresenter( + stateObservable: stateObservable, + viewModelFactory: viewModelFactory, + chainAsset: chainAsset, + selectClosureStrategy: selectClosureStrategy, + selectClosure: selectClosure + ) else { + return nil + } + + let view = SwapAssetsOperationViewController( + presenter: presenter, + keyboardAppearanceStrategy: EventDrivenKeyboardStrategy(events: [.viewDidAppear]), + createViewClosure: { SwapAssetsOperationViewLayout() }, + localizableTitle: title, + localizationManager: LocalizationManager.shared + ) + + presenter.view = view + + return view + } + + private static func createPresenter( + stateObservable: AssetListModelObservable, + viewModelFactory: AssetListAssetViewModelFactoryProtocol, + chainAsset: ChainAsset?, + selectClosureStrategy: SubmoduleNavigationStrategy, + selectClosure: @escaping (ChainAsset) -> Void + ) -> SwapAssetsOperationPresenter? { + let chainRegistry = ChainRegistryFacade.sharedRegistry + + let operationQueue = OperationManagerFacade.sharedDefaultQueue + + let assetConversionAggregator = AssetConversionAggregationFactory( + chainRegistry: chainRegistry, + operationQueue: OperationManagerFacade.sharedDefaultQueue + ) + + let interactor = SwapAssetsOperationInteractor( + stateObservable: stateObservable, + chainAsset: chainAsset, + assetConversionAggregation: assetConversionAggregator, + operationQueue: operationQueue, + logger: Logger.shared + ) + + let presenter = SwapAssetsOperationPresenter( + selectClosure: selectClosure, + selectClosureStrategy: selectClosureStrategy, + interactor: interactor, + viewModelFactory: viewModelFactory, + localizationManager: LocalizationManager.shared, + wireframe: SwapAssetsOperationWireframe(), + logger: Logger.shared + ) + + interactor.presenter = presenter + + return presenter + } +} diff --git a/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationViewLayout.swift b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationViewLayout.swift new file mode 100644 index 0000000000..7546b940f2 --- /dev/null +++ b/novawallet/Modules/AssetsSearch/Swaps/SwapAssetsOperationViewLayout.swift @@ -0,0 +1,26 @@ +import UIKit + +final class SwapAssetsOperationViewLayout: BaseAssetsSearchViewLayout { + let activityIndicator: UIActivityIndicatorView = .create { view in + view.style = .medium + view.tintColor = R.color.colorIconPrimary() + view.hidesWhenStopped = true + } + + override func createSearchView() -> SearchViewProtocol { + let view = TopCustomSearchView() + view.searchBar.textField.autocorrectionType = .no + view.searchBar.textField.autocapitalizationType = .none + return view + } + + override func setup() { + backgroundColor = R.color.colorSecondaryScreenBackground() + super.setup() + + addSubview(activityIndicator) + activityIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } +} diff --git a/novawallet/Modules/MessageSheet/TitleDetails/TitleDetailsSheetLayout.swift b/novawallet/Modules/MessageSheet/TitleDetails/TitleDetailsSheetLayout.swift index dc028268cf..c009e8700f 100644 --- a/novawallet/Modules/MessageSheet/TitleDetails/TitleDetailsSheetLayout.swift +++ b/novawallet/Modules/MessageSheet/TitleDetails/TitleDetailsSheetLayout.swift @@ -83,3 +83,27 @@ final class TitleDetailsSheetViewLayout: UIView { } } } + +extension TitleDetailsSheetViewLayout { + func contentHeight(model: TitleDetailsSheetViewModel, locale: Locale) -> CGFloat { + let titleHeight = height(for: titleLabel, with: model.title.value(for: locale)) + let messageHeight = height(for: detailsLabel, with: model.message.value(for: locale)) + let topOffset: CGFloat = 10 + let bottomOffset: CGFloat = 16 + let hasAnyActionButton = model.mainAction != nil || model.secondaryAction != nil + let buttonHeight: CGFloat = hasAnyActionButton ? UIConstants.actionHeight : 0 + return topOffset + titleHeight + 10 + messageHeight + buttonHeight + bottomOffset + } + + private func height(for label: UILabel, with text: String) -> CGFloat { + let width = UIScreen.main.bounds.width - UIConstants.horizontalInset * 2 + let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude) + let boundingBox = text.boundingRect( + with: constraintRect, + options: .usesLineFragmentOrigin, + attributes: [.font: label.font], + context: nil + ) + return boundingBox.height + } +} diff --git a/novawallet/Modules/MessageSheet/TitleDetails/TitleDetailsSheetViewFactory.swift b/novawallet/Modules/MessageSheet/TitleDetails/TitleDetailsSheetViewFactory.swift index aeefb4a164..1eb659c27a 100644 --- a/novawallet/Modules/MessageSheet/TitleDetails/TitleDetailsSheetViewFactory.swift +++ b/novawallet/Modules/MessageSheet/TitleDetails/TitleDetailsSheetViewFactory.swift @@ -24,4 +24,33 @@ struct TitleDetailsSheetViewFactory { return view } + + static func createContentSizedView( + from viewModel: TitleDetailsSheetViewModel, + allowsSwipeDown: Bool = true + ) -> MessageSheetViewProtocol { + let wireframe = MessageSheetWireframe() + + let presenter = MessageSheetPresenter(wireframe: wireframe) + + let view = TitleDetailsSheetViewController( + presenter: presenter, + viewModel: viewModel, + localizationManager: LocalizationManager.shared + ) + + view.allowsSwipeDown = allowsSwipeDown + let height = view.rootView.contentHeight( + model: viewModel, + locale: LocalizationManager.shared.selectedLocale + ) + + view.preferredContentSize = .init( + width: UIView.noIntrinsicMetric, + height: height + ) + presenter.view = view + + return view + } } diff --git a/novawallet/Modules/OperationDetails/Model/OperationDetailsModel.swift b/novawallet/Modules/OperationDetails/Model/OperationDetailsModel.swift index 8f33115776..e00dd0f81e 100644 --- a/novawallet/Modules/OperationDetails/Model/OperationDetailsModel.swift +++ b/novawallet/Modules/OperationDetails/Model/OperationDetailsModel.swift @@ -15,6 +15,7 @@ struct OperationDetailsModel { case contract(_ model: OperationContractCallModel) case poolReward(_ model: OperationPoolRewardOrSlashModel) case poolSlash(_ model: OperationPoolRewardOrSlashModel) + case swap(_ model: OperationSwapModel) } let time: Date diff --git a/novawallet/Modules/OperationDetails/Model/OperationSwapModel.swift b/novawallet/Modules/OperationDetails/Model/OperationSwapModel.swift new file mode 100644 index 0000000000..d0c87dafa5 --- /dev/null +++ b/novawallet/Modules/OperationDetails/Model/OperationSwapModel.swift @@ -0,0 +1,18 @@ +import Foundation +import BigInt + +struct OperationSwapModel { + let txHash: String + let chain: ChainModel + let assetIn: AssetModel + let amountIn: BigUInt + let priceIn: PriceData? + let assetOut: AssetModel + let amountOut: BigUInt + let priceOut: PriceData? + let fee: BigUInt + let feePrice: PriceData? + let feeAsset: AssetModel + let wallet: WalletDisplayAddress + let direction: AssetConversion.Direction +} diff --git a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsContractProvider.swift b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsContractProvider.swift index fae701845f..12460bddae 100644 --- a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsContractProvider.swift +++ b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsContractProvider.swift @@ -6,10 +6,12 @@ final class OperationDetailsContractProvider: OperationDetailsBaseProvider {} extension OperationDetailsContractProvider: OperationDetailsDataProviderProtocol { func extractOperationData( replacingWith newFee: BigUInt?, - priceCalculator _: TokenPriceCalculatorProtocol?, - feePriceCalculator: TokenPriceCalculatorProtocol?, + calculatorFactory: PriceHistoryCalculatorFactoryProtocol, progressClosure: @escaping (OperationDetailsModel.OperationData?) -> Void ) { + let priceCalculator = calculatorFactory.createPriceCalculator(for: chainAsset.asset.priceId) + let feePriceCalculator = calculatorFactory.createPriceCalculator(for: chainAsset.chain.utilityAsset()?.priceId) + let fee: BigUInt = newFee ?? transaction.feeInPlankIntOrZero let feePriceData = feePriceCalculator?.calculatePrice(for: UInt64(bitPattern: transaction.timestamp)).map { PriceData.amount($0) diff --git a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDataProviderFactory.swift b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDataProviderFactory.swift index 01b16b4cb7..36e258c5f4 100644 --- a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDataProviderFactory.swift +++ b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDataProviderFactory.swift @@ -92,6 +92,12 @@ extension OperationDetailsDataProviderFactory: OperationDetailsDataProviderFacto runtimeService: runtimeService, operationQueue: operationQueue ) + case .swap: + return OperationDetailsSwapProvider( + selectedAccount: selectedAccount, + chainAsset: chainAsset, + transaction: transaction + ) } } } diff --git a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDataProviderProtocol.swift b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDataProviderProtocol.swift index 5f57292f28..56c41777a8 100644 --- a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDataProviderProtocol.swift +++ b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDataProviderProtocol.swift @@ -4,8 +4,7 @@ import BigInt protocol OperationDetailsDataProviderProtocol { func extractOperationData( replacingWith newFee: BigUInt?, - priceCalculator: TokenPriceCalculatorProtocol?, - feePriceCalculator: TokenPriceCalculatorProtocol?, + calculatorFactory: PriceHistoryCalculatorFactoryProtocol, progressClosure: @escaping (OperationDetailsModel.OperationData?) -> Void ) } diff --git a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDirectStakingProvider.swift b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDirectStakingProvider.swift index e8a816a78a..406b5bb053 100644 --- a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDirectStakingProvider.swift +++ b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsDirectStakingProvider.swift @@ -52,14 +52,14 @@ final class OperationDetailsDirectStakingProvider: OperationDetailsBaseProvider, extension OperationDetailsDirectStakingProvider: OperationDetailsDataProviderProtocol { func extractOperationData( replacingWith _: BigUInt?, - priceCalculator: TokenPriceCalculatorProtocol?, - feePriceCalculator _: TokenPriceCalculatorProtocol?, + calculatorFactory: PriceHistoryCalculatorFactoryProtocol, progressClosure: @escaping (OperationDetailsModel.OperationData?) -> Void ) { let context = try? transaction.call.map { try JSONDecoder().decode(HistoryRewardContext.self, from: $0) } + let priceCalculator = calculatorFactory.createPriceCalculator(for: chainAsset.asset.priceId) let eventId = getEventId(from: context) ?? transaction.txHash let amount = transaction.amountInPlankIntOrZero diff --git a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsExtrinsicProvider.swift b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsExtrinsicProvider.swift index fcb1a38b41..ea5e2a9e2c 100644 --- a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsExtrinsicProvider.swift +++ b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsExtrinsicProvider.swift @@ -6,8 +6,7 @@ final class OperationDetailsExtrinsicProvider: OperationDetailsBaseProvider {} extension OperationDetailsExtrinsicProvider: OperationDetailsDataProviderProtocol { func extractOperationData( replacingWith newFee: BigUInt?, - priceCalculator _: TokenPriceCalculatorProtocol?, - feePriceCalculator: TokenPriceCalculatorProtocol?, + calculatorFactory: PriceHistoryCalculatorFactoryProtocol, progressClosure: @escaping (OperationDetailsModel.OperationData?) -> Void ) { guard let accountAddress = accountAddress else { @@ -15,6 +14,7 @@ extension OperationDetailsExtrinsicProvider: OperationDetailsDataProviderProtoco return } + let feePriceCalculator = calculatorFactory.createPriceCalculator(for: chain.utilityAsset()?.priceId) let fee = newFee ?? transaction.feeInPlankIntOrZero let feePriceData = feePriceCalculator?.calculatePrice(for: UInt64(bitPattern: transaction.timestamp)).map { PriceData.amount($0) diff --git a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsPoolStakingProvider.swift b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsPoolStakingProvider.swift index 2be201ea03..5545dd25b7 100644 --- a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsPoolStakingProvider.swift +++ b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsPoolStakingProvider.swift @@ -133,14 +133,14 @@ final class OperationDetailsPoolStakingProvider: OperationDetailsBaseProvider, A extension OperationDetailsPoolStakingProvider: OperationDetailsDataProviderProtocol { func extractOperationData( replacingWith _: BigUInt?, - priceCalculator: TokenPriceCalculatorProtocol?, - feePriceCalculator _: TokenPriceCalculatorProtocol?, + calculatorFactory: PriceHistoryCalculatorFactoryProtocol, progressClosure: @escaping (OperationDetailsModel.OperationData?) -> Void ) { let optContext = try? transaction.call.map { try JSONDecoder().decode(HistoryPoolRewardContext.self, from: $0) } + let priceCalculator = calculatorFactory.createPriceCalculator(for: chainAsset.asset.priceId) let eventId = getEventId(from: optContext) ?? transaction.txHash let amount = transaction.amountInPlankIntOrZero let priceData = priceCalculator?.calculatePrice(for: UInt64(bitPattern: transaction.timestamp)).map { diff --git a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsSwapProvider.swift b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsSwapProvider.swift new file mode 100644 index 0000000000..5998fb5b76 --- /dev/null +++ b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsSwapProvider.swift @@ -0,0 +1,93 @@ +import Foundation +import BigInt +import RobinHood + +final class OperationDetailsSwapProvider { + let selectedAccount: MetaChainAccountResponse + let chainAsset: ChainAsset + let transaction: TransactionHistoryItem + var chain: ChainModel { chainAsset.chain } + + init( + selectedAccount: MetaChainAccountResponse, + chainAsset: ChainAsset, + transaction: TransactionHistoryItem + ) { + self.selectedAccount = selectedAccount + self.chainAsset = chainAsset + self.transaction = transaction + } +} + +extension OperationDetailsSwapProvider: OperationDetailsDataProviderProtocol { + func extractOperationData( + replacingWith newFee: BigUInt?, + calculatorFactory: PriceHistoryCalculatorFactoryProtocol, + progressClosure: @escaping (OperationDetailsModel.OperationData?) -> Void + ) { + guard + let swap = transaction.swap, + let assetIn = chain.assetOrNil(for: swap.assetIdIn), + let assetOut = chain.assetOrNil(for: swap.assetIdOut), + let feeAsset = chain.assetOrNative(for: transaction.feeAssetId), + let wallet = WalletDisplayAddress(response: selectedAccount) else { + progressClosure(nil) + return + } + + let direction: AssetConversion.Direction = assetIn.assetId == chainAsset.asset.assetId ? .sell : .buy + let timestamp = UInt64(bitPattern: transaction.timestamp) + + let priceIn = calculatePrice( + calculatorFactory: calculatorFactory, + assetModel: assetIn, + timestamp: timestamp + ) + + let priceOut = calculatePrice( + calculatorFactory: calculatorFactory, + assetModel: assetOut, + timestamp: timestamp + ) + + let feePriceData = calculatePrice( + calculatorFactory: calculatorFactory, + assetModel: feeAsset, + timestamp: timestamp + ) + + let fee = newFee ?? transaction.feeInPlankIntOrZero + let txId = transaction.txHash + + let model = OperationSwapModel( + txHash: txId, + chain: chain, + assetIn: assetIn, + amountIn: BigUInt(swap.amountIn) ?? 0, + priceIn: priceIn, + assetOut: assetOut, + amountOut: BigUInt(swap.amountOut) ?? 0, + priceOut: priceOut, + fee: fee, + feePrice: feePriceData, + feeAsset: feeAsset, + wallet: wallet, + direction: direction + ) + progressClosure(.swap(model)) + } + + private func calculatePrice( + calculatorFactory: PriceHistoryCalculatorFactoryProtocol, + assetModel: AssetModel?, + timestamp: UInt64 + ) -> PriceData? { + guard let priceId = assetModel?.priceId else { + return nil + } + let provider = calculatorFactory.createPriceCalculator(for: priceId) + return provider?.calculatePrice(for: timestamp).map { + PriceData.amount($0) + } + } +} diff --git a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsTransferProvider.swift b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsTransferProvider.swift index 546002dd27..f8889ce08f 100644 --- a/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsTransferProvider.swift +++ b/novawallet/Modules/OperationDetails/OperationDataProviders/OperationDetailsTransferProvider.swift @@ -27,14 +27,15 @@ final class OperationDetailsTransferProvider: OperationDetailsBaseProvider, Acco extension OperationDetailsTransferProvider: OperationDetailsDataProviderProtocol { func extractOperationData( replacingWith newFee: BigUInt?, - priceCalculator: TokenPriceCalculatorProtocol?, - feePriceCalculator: TokenPriceCalculatorProtocol?, + calculatorFactory: PriceHistoryCalculatorFactoryProtocol, progressClosure: @escaping (OperationDetailsModel.OperationData?) -> Void ) { guard let accountAddress = accountAddress else { progressClosure(nil) return } + let priceCalculator = calculatorFactory.createPriceCalculator(for: chainAsset.asset.priceId) + let feePriceCalculator = calculatorFactory.createPriceCalculator(for: chainAsset.chain.utilityAsset()?.priceId) let peerAddress = (transaction.sender == accountAddress ? transaction.receiver : transaction.sender) ?? transaction.sender let accountId = try? peerAddress.toAccountId(using: chain.chainFormat) diff --git a/novawallet/Modules/OperationDetails/OperationDetailsBaseInteractor.swift b/novawallet/Modules/OperationDetails/OperationDetailsBaseInteractor.swift new file mode 100644 index 0000000000..61947b8ed9 --- /dev/null +++ b/novawallet/Modules/OperationDetails/OperationDetailsBaseInteractor.swift @@ -0,0 +1,160 @@ +import Foundation +import BigInt +import RobinHood + +enum OperationDetailsInteractorError: Error { + case unsupportTxType +} + +class OperationDetailsBaseInteractor: AccountFetching, AnyCancellableCleaning { + weak var presenter: OperationDetailsInteractorOutputProtocol? + + let transaction: TransactionHistoryItem + let chainAsset: ChainAsset + + var chain: ChainModel { chainAsset.chain } + + let transactionLocalSubscriptionFactory: TransactionLocalSubscriptionFactoryProtocol + let priceLocalSubscriptionFactory: PriceProviderFactoryProtocol + let operationDataProvider: OperationDetailsDataProviderProtocol + var priceProviders: [AssetModel.PriceId: AnySingleValueProvider] = [:] + + private var transactionProvider: StreamableProvider? + private var priceCalculators: [AssetModel.PriceId: TokenPriceCalculatorProtocol] = [:] + private var calculatorFactory = PriceHistoryCalculatorFactory() + + init( + transaction: TransactionHistoryItem, + chainAsset: ChainAsset, + transactionLocalSubscriptionFactory: TransactionLocalSubscriptionFactoryProtocol, + currencyManager: CurrencyManagerProtocol, + priceLocalSubscriptionFactory: PriceProviderFactoryProtocol, + operationDataProvider: OperationDetailsDataProviderProtocol + ) { + self.transaction = transaction + self.chainAsset = chainAsset + self.transactionLocalSubscriptionFactory = transactionLocalSubscriptionFactory + self.priceLocalSubscriptionFactory = priceLocalSubscriptionFactory + self.operationDataProvider = operationDataProvider + self.currencyManager = currencyManager + } + + private func extractStatus( + overridingBy newStatus: OperationDetailsModel.Status? + ) -> OperationDetailsModel.Status { + if let newStatus = newStatus { + return newStatus + } else { + switch transaction.status { + case .success: + return .completed + case .pending: + return .pending + case .failed: + return .failed + } + } + } + + private func extractOperationData( + replacingIfExists newFee: BigUInt?, + _ completion: @escaping (OperationDetailsModel.OperationData?) -> Void + ) { + operationDataProvider.extractOperationData( + replacingWith: newFee, + calculatorFactory: calculatorFactory, + progressClosure: completion + ) + } + + private func provideModel( + for operationData: OperationDetailsModel.OperationData, + overridingBy newStatus: OperationDetailsModel.Status? + ) { + let time = Date(timeIntervalSince1970: TimeInterval(transaction.timestamp)) + let status = extractStatus(overridingBy: newStatus) + + let details = OperationDetailsModel( + time: time, + status: status, + operation: operationData + ) + + presenter?.didReceiveDetails(result: .success(details)) + } + + private func provideModel( + overridingBy newStatus: OperationDetailsModel.Status?, + newFee: BigUInt? + ) { + extractOperationData(replacingIfExists: newFee) { [weak self] operationData in + if let operationData = operationData { + self?.provideModel(for: operationData, overridingBy: newStatus) + } else { + let error = OperationDetailsInteractorError.unsupportTxType + self?.presenter?.didReceiveDetails(result: .failure(error)) + } + } + } + + func setupPriceHistorySubscription() { + fatalError("This function should be overriden") + } +} + +extension OperationDetailsBaseInteractor: OperationDetailsInteractorInputProtocol { + func setup() { + provideModel(overridingBy: nil, newFee: nil) + + transactionProvider = subscribeToTransaction(for: transaction.identifier, chainId: chain.chainId) + + setupPriceHistorySubscription() + } +} + +extension OperationDetailsBaseInteractor: TransactionLocalStorageSubscriber, + TransactionLocalSubscriptionHandler { + func handleTransactions(result: Result<[DataProviderChange], Error>) { + switch result { + case let .success(changes): + if let transaction = changes.reduceToLastChange() { + let newFee = transaction.fee.flatMap { BigUInt($0) } + switch transaction.status { + case .success: + provideModel(overridingBy: .completed, newFee: newFee) + case .failed: + provideModel(overridingBy: .failed, newFee: newFee) + case .pending: + provideModel(overridingBy: .pending, newFee: newFee) + } + } + case let .failure(error): + presenter?.didReceiveDetails(result: .failure(error)) + } + } +} + +extension OperationDetailsBaseInteractor: PriceLocalStorageSubscriber, PriceLocalSubscriptionHandler { + func handlePriceHistory( + result: Result, + priceId: AssetModel.PriceId + ) { + switch result { + case let .success(history): + if let history = history { + calculatorFactory.replace(history: history, priceId: priceId) + provideModel(overridingBy: nil, newFee: nil) + } + case let .failure(error): + presenter?.didReceiveDetails(result: .failure(error)) + } + } +} + +extension OperationDetailsBaseInteractor: SelectedCurrencyDepending { + func applyCurrency() { + if presenter != nil { + setupPriceHistorySubscription() + } + } +} diff --git a/novawallet/Modules/OperationDetails/OperationDetailsInteractor.swift b/novawallet/Modules/OperationDetails/OperationDetailsInteractor.swift index 2be1c8bba8..0eb1a34c75 100644 --- a/novawallet/Modules/OperationDetails/OperationDetailsInteractor.swift +++ b/novawallet/Modules/OperationDetails/OperationDetailsInteractor.swift @@ -2,183 +2,23 @@ import Foundation import BigInt import RobinHood -enum OperationDetailsInteractorError: Error { - case unsupportTxType -} - -final class OperationDetailsInteractor: AccountFetching, AnyCancellableCleaning { - weak var presenter: OperationDetailsInteractorOutputProtocol? - - let transaction: TransactionHistoryItem - let chainAsset: ChainAsset - - var chain: ChainModel { chainAsset.chain } - - let transactionLocalSubscriptionFactory: TransactionLocalSubscriptionFactoryProtocol - let priceLocalSubscriptionFactory: PriceProviderFactoryProtocol - let operationDataProvider: OperationDetailsDataProviderProtocol - - private var transactionProvider: StreamableProvider? - private var priceProvider: AnySingleValueProvider? - private var feePriceProvider: AnySingleValueProvider? - - private var priceCalculator: TokenPriceCalculatorProtocol? - private var feePriceCalculator: TokenPriceCalculatorProtocol? - - init( - transaction: TransactionHistoryItem, - chainAsset: ChainAsset, - transactionLocalSubscriptionFactory: TransactionLocalSubscriptionFactoryProtocol, - currencyManager: CurrencyManagerProtocol, - priceLocalSubscriptionFactory: PriceProviderFactoryProtocol, - operationDataProvider: OperationDetailsDataProviderProtocol - ) { - self.transaction = transaction - self.chainAsset = chainAsset - self.transactionLocalSubscriptionFactory = transactionLocalSubscriptionFactory - self.priceLocalSubscriptionFactory = priceLocalSubscriptionFactory - self.operationDataProvider = operationDataProvider - self.currencyManager = currencyManager - } - - private func extractStatus( - overridingBy newStatus: OperationDetailsModel.Status? - ) -> OperationDetailsModel.Status { - if let newStatus = newStatus { - return newStatus - } else { - switch transaction.status { - case .success: - return .completed - case .pending: - return .pending - case .failed: - return .failed - } - } - } - - private func extractOperationData( - replacingIfExists newFee: BigUInt?, - _ completion: @escaping (OperationDetailsModel.OperationData?) -> Void - ) { - operationDataProvider.extractOperationData( - replacingWith: newFee, - priceCalculator: priceCalculator, - feePriceCalculator: feePriceCalculator, - progressClosure: completion - ) - } - - private func provideModel( - for operationData: OperationDetailsModel.OperationData, - overridingBy newStatus: OperationDetailsModel.Status? - ) { - let time = Date(timeIntervalSince1970: TimeInterval(transaction.timestamp)) - let status = extractStatus(overridingBy: newStatus) - - let details = OperationDetailsModel( - time: time, - status: status, - operation: operationData - ) - - presenter?.didReceiveDetails(result: .success(details)) - } - - private func provideModel( - overridingBy newStatus: OperationDetailsModel.Status?, - newFee: BigUInt? - ) { - extractOperationData(replacingIfExists: newFee) { [weak self] operationData in - if let operationData = operationData { - self?.provideModel(for: operationData, overridingBy: newStatus) - } else { - let error = OperationDetailsInteractorError.unsupportTxType - self?.presenter?.didReceiveDetails(result: .failure(error)) - } +final class OperationDetailsInteractor: OperationDetailsBaseInteractor { + override func setupPriceHistorySubscription() { + let priceId = chainAsset.asset.priceId + + if let priceId = priceId { + priceProviders[priceId] = subscribeToPriceHistory( + for: priceId, + currency: selectedCurrency + ) } - } - - private func setupPriceHistorySubscription() { - priceProvider = priceHistoryProvider(for: chainAsset.asset) - - let utilityAsset = chainAsset.chain.utilityAsset() - feePriceProvider = utilityAsset?.priceId == chainAsset.asset.priceId ? - priceProvider : priceHistoryProvider(for: utilityAsset) - } - - private func priceHistoryProvider(for asset: AssetModel?) -> AnySingleValueProvider? { - guard let asset = asset else { - return nil - } - guard let priceId = asset.priceId else { - return nil - } - - return subscribeToPriceHistory(for: priceId, currency: selectedCurrency) - } -} - -extension OperationDetailsInteractor: OperationDetailsInteractorInputProtocol { - func setup() { - provideModel(overridingBy: nil, newFee: nil) - - transactionProvider = subscribeToTransaction(for: transaction.identifier, chainId: chain.chainId) - - setupPriceHistorySubscription() - } -} - -extension OperationDetailsInteractor: TransactionLocalStorageSubscriber, - TransactionLocalSubscriptionHandler { - func handleTransactions(result: Result<[DataProviderChange], Error>) { - switch result { - case let .success(changes): - if let transaction = changes.reduceToLastChange() { - let newFee = transaction.fee.flatMap { BigUInt($0) } - switch transaction.status { - case .success: - provideModel(overridingBy: .completed, newFee: newFee) - case .failed: - provideModel(overridingBy: .failed, newFee: newFee) - case .pending: - provideModel(overridingBy: .pending, newFee: newFee) - } - } - case let .failure(error): - presenter?.didReceiveDetails(result: .failure(error)) - } - } -} - -extension OperationDetailsInteractor: PriceLocalStorageSubscriber, PriceLocalSubscriptionHandler { - func handlePriceHistory( - result: Result, - priceId: AssetModel.PriceId - ) { - switch result { - case let .success(history): - if let history = history { - if chainAsset.asset.priceId == priceId { - priceCalculator = TokenPriceCalculator(history: history) - } - if chainAsset.chain.utilityAsset()?.priceId == priceId { - feePriceCalculator = TokenPriceCalculator(history: history) - } - provideModel(overridingBy: nil, newFee: nil) - } - - case let .failure(error): - presenter?.didReceiveDetails(result: .failure(error)) - } - } -} -extension OperationDetailsInteractor: SelectedCurrencyDepending { - func applyCurrency() { - if presenter != nil { - setupPriceHistorySubscription() + if let utilityAssetPriceId = chainAsset.chain.utilityAsset()?.priceId, + utilityAssetPriceId != priceId { + priceProviders[utilityAssetPriceId] = subscribeToPriceHistory( + for: utilityAssetPriceId, + currency: selectedCurrency + ) } } } diff --git a/novawallet/Modules/OperationDetails/OperationDetailsPresenter.swift b/novawallet/Modules/OperationDetails/OperationDetailsPresenter.swift index ba36da0d06..d0a13d7e8c 100644 --- a/novawallet/Modules/OperationDetails/OperationDetailsPresenter.swift +++ b/novawallet/Modules/OperationDetails/OperationDetailsPresenter.swift @@ -105,6 +105,8 @@ extension OperationDetailsPresenter: OperationDetailsPresenterProtocol { return } presentAddressOptions(address) + case let .swap(model): + presentAddressOptions(model.wallet.address) case .none: break } @@ -135,13 +137,16 @@ extension OperationDetailsPresenter: OperationDetailsPresenterProtocol { presentTransactionHashOptions(contractModel.txHash) case let .poolReward(poolRewardOrSlashModel), let .poolSlash(poolRewardOrSlashModel): presentEventIdOptions(poolRewardOrSlashModel.eventId) + case let .swap(swapModel): + presentTransactionHashOptions(swapModel.txHash) case .none: break } } - func send() { - if case let .transfer(transferModel) = model?.operation { + func repeatOperation() { + switch model?.operation { + case let .transfer(transferModel): let peer = transferModel.outgoing ? transferModel.receiver : transferModel.sender wireframe.showSend( @@ -149,8 +154,34 @@ extension OperationDetailsPresenter: OperationDetailsPresenterProtocol { displayAddress: peer, chainAsset: chainAsset ) + case let .swap(swapModel): + let payChainAsset = ChainAsset(chain: swapModel.chain, asset: swapModel.assetIn) + let receiveChainAsset = ChainAsset(chain: swapModel.chain, asset: swapModel.assetOut) + let feeChainAsset = ChainAsset(chain: swapModel.chain, asset: swapModel.feeAsset) + let amount = swapModel.direction == .sell ? + swapModel.amountIn.decimal(precision: payChainAsset.asset.precision) : + swapModel.amountOut.decimal(precision: receiveChainAsset.asset.precision) + let swapSetupInitState = SwapSetupInitState( + payChainAsset: payChainAsset, + receiveChainAsset: receiveChainAsset, + feeChainAsset: feeChainAsset, + amount: amount, + direction: swapModel.direction + ) + + wireframe.showSwapSetup(from: view, state: swapSetupInitState) + default: + break } } + + func showRateInfo() { + wireframe.showRateInfo(from: view) + } + + func showNetworkFeeInfo() { + wireframe.showFeeInfo(from: view) + } } extension OperationDetailsPresenter: OperationDetailsInteractorOutputProtocol { diff --git a/novawallet/Modules/OperationDetails/OperationDetailsProtocols.swift b/novawallet/Modules/OperationDetails/OperationDetailsProtocols.swift index e94ed998f0..c7bf89b54b 100644 --- a/novawallet/Modules/OperationDetails/OperationDetailsProtocols.swift +++ b/novawallet/Modules/OperationDetails/OperationDetailsProtocols.swift @@ -8,7 +8,9 @@ protocol OperationDetailsPresenterProtocol: AnyObject { func showSenderActions() func showRecepientActions() func showOperationActions() - func send() + func repeatOperation() + func showRateInfo() + func showNetworkFeeInfo() } protocol OperationDetailsInteractorInputProtocol: AnyObject { @@ -20,10 +22,15 @@ protocol OperationDetailsInteractorOutputProtocol: AnyObject { } protocol OperationDetailsWireframeProtocol: AlertPresentable, ErrorPresentable, - AddressOptionsPresentable, OperationIdOptionsPresentable { + AddressOptionsPresentable, OperationIdOptionsPresentable, ShortTextInfoPresentable { func showSend( from view: OperationDetailsViewProtocol?, displayAddress: DisplayAddress, chainAsset: ChainAsset ) + + func showSwapSetup( + from: OperationDetailsViewProtocol?, + state: SwapSetupInitState + ) } diff --git a/novawallet/Modules/OperationDetails/OperationDetailsViewController.swift b/novawallet/Modules/OperationDetails/OperationDetailsViewController.swift index a86aa7855d..3cf1854c56 100644 --- a/novawallet/Modules/OperationDetails/OperationDetailsViewController.swift +++ b/novawallet/Modules/OperationDetails/OperationDetailsViewController.swift @@ -127,7 +127,7 @@ final class OperationDetailsViewController: UIViewController, ViewHolder { sendButton.addTarget( self, - action: #selector(actionSend), + action: #selector(actionRepeatOperation), for: .touchUpInside ) } @@ -264,6 +264,44 @@ final class OperationDetailsViewController: UIViewController, ViewHolder { return view } + private func applySwap( + viewModel: OperationSwapViewModel + ) { + let swapView: OperationDetailsSwapView = rootView.setupLocalizableView() + swapView.locale = selectedLocale + swapView.bind(viewModel: viewModel) + + let repeatOperationButton = rootView.setupActionButton() + repeatOperationButton.imageWithTitleView?.title = R.string.localizable.commonActionRepeatOperation( + preferredLanguages: selectedLocale.rLanguages + ) + swapView.rateCell.addTarget( + self, + action: #selector(actionRate), + for: .touchUpInside + ) + swapView.networkFeeCell.addTarget( + self, + action: #selector(actionNetworkFee), + for: .touchUpInside + ) + swapView.accountCell.addTarget( + self, + action: #selector(actionSender), + for: .touchUpInside + ) + swapView.transactionHashView.addTarget( + self, + action: #selector(actionOperationId), + for: .touchUpInside + ) + repeatOperationButton.addTarget( + self, + action: #selector(actionRepeatOperation), + for: .touchUpInside + ) + } + @objc func actionSender() { presenter.showSenderActions() } @@ -276,8 +314,16 @@ final class OperationDetailsViewController: UIViewController, ViewHolder { presenter.showRecepientActions() } - @objc func actionSend() { - presenter.send() + @objc func actionRepeatOperation() { + presenter.repeatOperation() + } + + @objc func actionRate() { + presenter.showRateInfo() + } + + @objc func actionNetworkFee() { + presenter.showNetworkFeeInfo() } } @@ -305,6 +351,8 @@ extension OperationDetailsViewController: OperationDetailsViewProtocol { applyPoolReward(viewModel: poolRewardViewModel, networkViewModel: networkViewModel) case let .poolSlash(poolSlashViewModel): applyPoolSlash(viewModel: poolSlashViewModel, networkViewModel: networkViewModel) + case let .swap(swapViewModel): + applySwap(viewModel: swapViewModel) } } } diff --git a/novawallet/Modules/OperationDetails/OperationDetailsViewFactory.swift b/novawallet/Modules/OperationDetails/OperationDetailsViewFactory.swift index 08cb0360b3..0f0e0927cc 100644 --- a/novawallet/Modules/OperationDetails/OperationDetailsViewFactory.swift +++ b/novawallet/Modules/OperationDetails/OperationDetailsViewFactory.swift @@ -6,7 +6,8 @@ import RobinHood struct OperationDetailsViewFactory { static func createView( for transaction: TransactionHistoryItem, - chainAsset: ChainAsset + chainAsset: ChainAsset, + operationState: AssetOperationState ) -> OperationDetailsViewProtocol? { guard let currencyManager = CurrencyManager.shared, @@ -37,42 +38,39 @@ struct OperationDetailsViewFactory { storageFacade: SubstrateDataStorageFacade.shared, operationQueue: OperationManagerFacade.sharedDefaultQueue ) + let interactor: OperationDetailsBaseInteractor + + if transaction.swap != nil { + interactor = createSwapInteractor( + transaction: transaction, + chainAsset: chainAsset, + transactionLocalSubscriptionFactory: transactionLocalSubscriptionFactory, + currencyManager: currencyManager, + priceLocalSubscriptionFactory: PriceProviderFactory.shared, + operationDataProvider: operationDetailsDataProvider + ) + } else { + interactor = createInteractor( + transaction: transaction, + chainAsset: chainAsset, + transactionLocalSubscriptionFactory: transactionLocalSubscriptionFactory, + currencyManager: currencyManager, + priceLocalSubscriptionFactory: PriceProviderFactory.shared, + operationDataProvider: operationDetailsDataProvider + ) + } - let interactor = OperationDetailsInteractor( - transaction: transaction, - chainAsset: chainAsset, - transactionLocalSubscriptionFactory: transactionLocalSubscriptionFactory, - currencyManager: currencyManager, - priceLocalSubscriptionFactory: PriceProviderFactory.shared, - operationDataProvider: operationDetailsDataProvider + let wireframe = OperationDetailsWireframe( + operationState: operationState ) - let wireframe = OperationDetailsWireframe() - let localizationManager = LocalizationManager.shared let priceAssetInfoFactory = PriceAssetInfoFactory(currencyManager: currencyManager) - let balanceViewModelFactory = BalanceViewModelFactory( - targetAssetInfo: chainAsset.assetDisplayInfo, - priceAssetInfoFactory: priceAssetInfoFactory - ) - - let feeViewModelFactory: BalanceViewModelFactoryProtocol? - - if - let utilityAsset = chainAsset.chain.utilityAssets().first, - utilityAsset.assetId != chainAsset.asset.assetId { - feeViewModelFactory = BalanceViewModelFactory( - targetAssetInfo: utilityAsset.displayInfo(with: chainAsset.chain.icon), - priceAssetInfoFactory: priceAssetInfoFactory - ) - } else { - feeViewModelFactory = nil - } + let balanceViewModelFactoryFacade = BalanceViewModelFactoryFacade(priceAssetInfoFactory: priceAssetInfoFactory) let viewModelFactory = OperationDetailsViewModelFactory( - balanceViewModelFactory: balanceViewModelFactory, - feeViewModelFactory: feeViewModelFactory + balanceViewModelFactoryFacade: balanceViewModelFactoryFacade ) let presenter = OperationDetailsPresenter( @@ -93,4 +91,40 @@ struct OperationDetailsViewFactory { return view } + + static func createSwapInteractor( + transaction: TransactionHistoryItem, + chainAsset: ChainAsset, + transactionLocalSubscriptionFactory: TransactionLocalSubscriptionFactoryProtocol, + currencyManager: CurrencyManagerProtocol, + priceLocalSubscriptionFactory: PriceProviderFactoryProtocol, + operationDataProvider: OperationDetailsDataProviderProtocol + ) -> OperationDetailsBaseInteractor { + OperationSwapDetailsInteractor( + transaction: transaction, + chainAsset: chainAsset, + transactionLocalSubscriptionFactory: transactionLocalSubscriptionFactory, + currencyManager: currencyManager, + priceLocalSubscriptionFactory: priceLocalSubscriptionFactory, + operationDataProvider: operationDataProvider + ) + } + + static func createInteractor( + transaction: TransactionHistoryItem, + chainAsset: ChainAsset, + transactionLocalSubscriptionFactory: TransactionLocalSubscriptionFactoryProtocol, + currencyManager: CurrencyManagerProtocol, + priceLocalSubscriptionFactory: PriceProviderFactoryProtocol, + operationDataProvider: OperationDetailsDataProviderProtocol + ) -> OperationDetailsBaseInteractor { + OperationDetailsInteractor( + transaction: transaction, + chainAsset: chainAsset, + transactionLocalSubscriptionFactory: transactionLocalSubscriptionFactory, + currencyManager: currencyManager, + priceLocalSubscriptionFactory: priceLocalSubscriptionFactory, + operationDataProvider: operationDataProvider + ) + } } diff --git a/novawallet/Modules/OperationDetails/OperationDetailsViewLayout.swift b/novawallet/Modules/OperationDetails/OperationDetailsViewLayout.swift index edc504326a..a534f8d9df 100644 --- a/novawallet/Modules/OperationDetails/OperationDetailsViewLayout.swift +++ b/novawallet/Modules/OperationDetails/OperationDetailsViewLayout.swift @@ -23,9 +23,9 @@ final class OperationDetailsViewLayout: UIView { }() let containerView: ScrollableContainerView = { - let view = ScrollableContainerView(axis: .vertical, respectsSafeArea: false) + let view = ScrollableContainerView(axis: .vertical, respectsSafeArea: true) view.stackView.alignment = .center - view.stackView.layoutMargins = UIEdgeInsets(top: 10.0, left: 0.0, bottom: 0.0, right: 0.0) + view.stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0.0, bottom: 0.0, right: 0.0) view.stackView.isLayoutMarginsRelativeArrangement = true return view }() diff --git a/novawallet/Modules/OperationDetails/OperationDetailsWireframe.swift b/novawallet/Modules/OperationDetails/OperationDetailsWireframe.swift index a63a06c05d..904c444e74 100644 --- a/novawallet/Modules/OperationDetails/OperationDetailsWireframe.swift +++ b/novawallet/Modules/OperationDetails/OperationDetailsWireframe.swift @@ -1,6 +1,14 @@ import Foundation final class OperationDetailsWireframe: OperationDetailsWireframeProtocol { + let operationState: AssetOperationState + + init( + operationState: AssetOperationState + ) { + self.operationState = operationState + } + func showSend( from view: OperationDetailsViewProtocol?, displayAddress: DisplayAddress, @@ -15,4 +23,19 @@ final class OperationDetailsWireframe: OperationDetailsWireframeProtocol { view?.controller.navigationController?.pushViewController(transferView.controller, animated: true) } + + func showSwapSetup( + from view: OperationDetailsViewProtocol?, + state: SwapSetupInitState + ) { + guard let swapView = SwapSetupViewFactory.createView( + assetListObservable: operationState.assetListObservable, + initState: state, + swapCompletionClosure: operationState.swapCompletionClosure + ) else { + return + } + + view?.controller.navigationController?.pushViewController(swapView.controller, animated: true) + } } diff --git a/novawallet/Modules/OperationDetails/OperationSwapDetailsInteractor.swift b/novawallet/Modules/OperationDetails/OperationSwapDetailsInteractor.swift new file mode 100644 index 0000000000..a19159377e --- /dev/null +++ b/novawallet/Modules/OperationDetails/OperationSwapDetailsInteractor.swift @@ -0,0 +1,22 @@ +import Foundation +import BigInt +import RobinHood + +final class OperationSwapDetailsInteractor: OperationDetailsBaseInteractor { + override func setupPriceHistorySubscription() { + guard let swap = transaction.swap else { + return + } + let priceAssetIn = chain.assetOrNil(for: swap.assetIdIn)?.priceId + let priceAssetOut = chain.assetOrNil(for: swap.assetIdOut)?.priceId + let feePriceId = chain.assetOrNative(for: transaction.feeAssetId)?.priceId + let prices = [ + priceAssetIn, + priceAssetOut, + feePriceId + ].compactMap { $0 } + Set(prices).forEach { + priceProviders[$0] = subscribeToPriceHistory(for: $0, currency: selectedCurrency) + } + } +} diff --git a/novawallet/Modules/OperationDetails/View/OperationDetailsSwapView.swift b/novawallet/Modules/OperationDetails/View/OperationDetailsSwapView.swift new file mode 100644 index 0000000000..7a03150d18 --- /dev/null +++ b/novawallet/Modules/OperationDetails/View/OperationDetailsSwapView.swift @@ -0,0 +1,124 @@ +import UIKit + +final class OperationDetailsSwapView: LocalizableView { + let senderTableView = StackTableView() + + let pairsView = SwapPairView() + let detailsTableView = StackTableView() + let walletTableView = StackTableView() + let transactionTableView = StackTableView() + + let rateCell: SwapInfoViewCell = .create { + $0.titleButton.imageWithTitleView?.titleColor = R.color.colorTextSecondary() + $0.titleButton.imageWithTitleView?.titleFont = .regularFootnote + } + + let networkFeeCell = SwapNetworkFeeViewCell() + + let walletCell = StackTableCell() + + let accountCell: StackInfoTableCell = .create { + $0.detailsLabel.lineBreakMode = .byTruncatingMiddle + } + + let transactionHashView: StackInfoTableCell = .create { + $0.detailsLabel.lineBreakMode = .byTruncatingMiddle + } + + let actionButton: TriangularedButton = .create { + $0.applyDefaultStyle() + } + + var locale: Locale = .current { + didSet { + if locale != oldValue { + setup(locale: locale) + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + setupStyle() + setupLayout() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func bind(viewModel: OperationSwapViewModel) { + pairsView.leftAssetView.bind(viewModel: viewModel.assetIn) + pairsView.rigthAssetView.bind(viewModel: viewModel.assetOut) + rateCell.bind(loadableViewModel: .loaded(value: viewModel.rate)) + networkFeeCell.bind(loadableViewModel: .loaded(value: .init( + isEditable: false, + balanceViewModel: viewModel.fee + ))) + walletCell.bind(viewModel: .init( + details: viewModel.wallet.walletName ?? "", + imageViewModel: viewModel.wallet.walletIcon + )) + accountCell.bind(viewModel: .init( + details: viewModel.wallet.address, + imageViewModel: viewModel.wallet.addressIcon + )) + transactionHashView.bind(details: viewModel.transactionHash) + + pairsView.leftAssetView.valueLabel.textColor = R.color.colorTextPrimary() + pairsView.rigthAssetView.valueLabel.textColor = R.color.colorTextPositive() + } + + private func setup(locale: Locale) { + rateCell.titleButton.imageWithTitleView?.title = R.string.localizable.swapsSetupDetailsRate( + preferredLanguages: locale.rLanguages) + networkFeeCell.titleButton.imageWithTitleView?.title = R.string.localizable.commonNetworkFee( + preferredLanguages: locale.rLanguages) + rateCell.titleButton.invalidateLayout() + networkFeeCell.titleButton.invalidateLayout() + + walletCell.titleLabel.text = R.string.localizable.commonWallet( + preferredLanguages: locale.rLanguages) + accountCell.titleLabel.text = R.string.localizable.commonAccount( + preferredLanguages: locale.rLanguages) + transactionHashView.titleLabel.text = R.string.localizable.commonTxId( + preferredLanguages: locale.rLanguages + ) + } + + func setupStyle() { + backgroundColor = .clear + } + + func setupLayout() { + addSubview(pairsView) + addSubview(detailsTableView) + addSubview(walletTableView) + addSubview(transactionTableView) + + pairsView.snp.makeConstraints { + $0.leading.trailing.top.equalToSuperview() + } + detailsTableView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.top.equalTo(pairsView.snp.bottom).offset(8) + } + walletTableView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.top.equalTo(detailsTableView.snp.bottom).offset(8) + } + transactionTableView.snp.makeConstraints { + $0.leading.trailing.bottom.equalToSuperview() + $0.top.equalTo(walletTableView.snp.bottom).offset(8) + $0.bottom.equalToSuperview().offset(24) + } + + detailsTableView.addArrangedSubview(rateCell) + detailsTableView.addArrangedSubview(networkFeeCell) + walletTableView.addArrangedSubview(walletCell) + walletTableView.addArrangedSubview(accountCell) + transactionTableView.addArrangedSubview(transactionHashView) + } +} diff --git a/novawallet/Modules/OperationDetails/ViewModel/OperationDetailsViewModel.swift b/novawallet/Modules/OperationDetails/ViewModel/OperationDetailsViewModel.swift index 32f65641f3..12bd933c52 100644 --- a/novawallet/Modules/OperationDetails/ViewModel/OperationDetailsViewModel.swift +++ b/novawallet/Modules/OperationDetails/ViewModel/OperationDetailsViewModel.swift @@ -9,6 +9,7 @@ struct OperationDetailsViewModel { case contract(_ viewModel: OperationContractCallViewModel) case poolReward(_ viewModel: OperationPoolRewardOrSlashViewModel) case poolSlash(_ viewModel: OperationPoolRewardOrSlashViewModel) + case swap(_ viewModel: OperationSwapViewModel) } let time: String diff --git a/novawallet/Modules/OperationDetails/ViewModel/OperationDetailsViewModelFactory.swift b/novawallet/Modules/OperationDetails/ViewModel/OperationDetailsViewModelFactory.swift index 4a7a2f9397..9deb65ceef 100644 --- a/novawallet/Modules/OperationDetails/ViewModel/OperationDetailsViewModelFactory.swift +++ b/novawallet/Modules/OperationDetails/ViewModel/OperationDetailsViewModelFactory.swift @@ -11,30 +11,28 @@ protocol OperationDetailsViewModelFactoryProtocol { } final class OperationDetailsViewModelFactory { - let balanceViewModelFactory: BalanceViewModelFactoryProtocol - let feeViewModelFactory: BalanceViewModelFactoryProtocol? let dateFormatter: LocalizableResource let networkViewModelFactory: NetworkViewModelFactoryProtocol let displayAddressViewModelFactory: DisplayAddressViewModelFactoryProtocol let quantityFormatter: LocalizableResource lazy var poolIconFactory: NominationPoolsIconFactoryProtocol = NominationPoolsIconFactory() + lazy var walletViewModelFactory = WalletAccountViewModelFactory() + let balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol init( - balanceViewModelFactory: BalanceViewModelFactoryProtocol, - feeViewModelFactory: BalanceViewModelFactoryProtocol?, dateFormatter: LocalizableResource = DateFormatter.txDetails, networkViewModelFactory: NetworkViewModelFactoryProtocol = NetworkViewModelFactory(), displayAddressViewModelFactory: DisplayAddressViewModelFactoryProtocol = DisplayAddressViewModelFactory(), quantityFormatter: LocalizableResource = - NumberFormatter.quantity.localizableResource() + NumberFormatter.quantity.localizableResource(), + balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol ) { - self.balanceViewModelFactory = balanceViewModelFactory - self.feeViewModelFactory = feeViewModelFactory self.dateFormatter = dateFormatter self.networkViewModelFactory = networkViewModelFactory self.displayAddressViewModelFactory = displayAddressViewModelFactory self.quantityFormatter = quantityFormatter + self.balanceViewModelFactoryFacade = balanceViewModelFactoryFacade } private func createIconViewModel( @@ -57,6 +55,9 @@ final class OperationDetailsViewModelFactory { } else { return nil } + case .swap: + let image = R.image.iconSwap()! + return StaticImageViewModel(image: image) } } @@ -68,6 +69,7 @@ final class OperationDetailsViewModelFactory { let amount: BigUInt let priceData: PriceData? let prefix: String + var precision = assetInfo.assetPrecision switch model { case let .transfer(model): @@ -98,17 +100,30 @@ final class OperationDetailsViewModelFactory { amount = model.amount priceData = model.priceData prefix = "-" + case let .swap(model): + switch model.direction { + case .sell: + amount = model.amountIn + priceData = model.priceIn + prefix = "-" + precision = model.assetIn.displayInfo.assetPrecision + case .buy: + amount = model.amountOut + priceData = model.priceOut + prefix = "+" + precision = model.assetOut.displayInfo.assetPrecision + } } return Decimal.fromSubstrateAmount( amount, - precision: assetInfo.assetPrecision + precision: precision ).map { amountDecimal in - let amountViewModel = balanceViewModelFactory.balanceFromPrice( - amountDecimal, + let amountViewModel = balanceViewModelFactoryFacade.balanceFromPrice( + targetAssetInfo: assetInfo, + amount: amountDecimal, priceData: priceData ).value(for: locale) - return BalanceViewModel(amount: prefix + amountViewModel.amount, price: amountViewModel.price) } } @@ -136,9 +151,9 @@ final class OperationDetailsViewModelFactory { model.fee, precision: feeAssetInfo.assetPrecision ).map { amount in - let viewModelFactory = feeViewModelFactory ?? balanceViewModelFactory - return viewModelFactory.balanceFromPrice( - amount, + balanceViewModelFactoryFacade.balanceFromPrice( + targetAssetInfo: feeAssetInfo, + amount: amount, priceData: model.feePriceData ).value(for: locale) } @@ -211,6 +226,105 @@ final class OperationDetailsViewModelFactory { return OperationPoolRewardOrSlashViewModel(eventId: model.eventId, pool: poolViewModel) } + private func createSwapViewModel( + from model: OperationSwapModel, + locale: Locale + ) -> OperationSwapViewModel { + let assetInViewModel = assetViewModel( + chain: model.chain, + asset: model.assetIn, + amount: model.amountIn, + priceData: model.priceIn, + locale: locale + ) + let assetOutViewModel = assetViewModel( + chain: model.chain, + asset: model.assetOut, + amount: model.amountOut, + priceData: model.priceOut, + locale: locale + ) + let rateViewModel = rateViewModel( + from: .init( + assetDisplayInfoIn: model.assetIn.displayInfo, + assetDisplayInfoOut: model.assetOut.displayInfo, + amountIn: model.amountIn, + amountOut: model.amountOut + ), + locale: locale + ) + let feeAmountDecimal = Decimal.fromSubstrateAmount( + model.fee, + precision: model.feeAsset.displayInfo.assetPrecision + ) ?? 0 + let feeBalanceViewModel = balanceViewModelFactoryFacade.balanceFromPrice( + targetAssetInfo: model.feeAsset.displayInfo, + amount: feeAmountDecimal, + priceData: model.feePrice + ).value(for: locale) + let walletViewModel = try? walletViewModelFactory.createViewModel(from: model.wallet) + + return OperationSwapViewModel( + direction: model.direction, + assetIn: assetInViewModel, + assetOut: assetOutViewModel, + rate: rateViewModel, + fee: feeBalanceViewModel, + wallet: walletViewModel ?? .init(walletName: nil, walletIcon: nil, address: "", addressIcon: nil), + transactionHash: model.txHash + ) + } + + private func assetViewModel( + chain: ChainModel, + asset: AssetModel, + amount: BigUInt, + priceData: PriceData?, + locale: Locale + ) -> SwapAssetAmountViewModel { + let networkViewModel = networkViewModelFactory.createViewModel(from: chain) + let assetIcon: ImageViewModelProtocol = asset.icon.map { RemoteImageViewModel(url: $0) } ?? + StaticImageViewModel(image: R.image.iconDefaultToken()!) + let amountDecimal = Decimal.fromSubstrateAmount( + amount, + precision: asset.displayInfo.assetPrecision + ) ?? 0 + let balanceViewModel = balanceViewModelFactoryFacade.balanceFromPrice( + targetAssetInfo: asset.displayInfo, + amount: amountDecimal, + priceData: priceData + ).value(for: locale) + + return .init( + imageViewModel: assetIcon, + hub: networkViewModel, + amount: balanceViewModel.amount, + price: balanceViewModel.price.map { $0.approximately() } + ) + } + + func rateViewModel(from params: RateParams, locale: Locale) -> String { + guard + let amountOutDecimal = Decimal.fromSubstrateAmount( + params.amountOut, + precision: params.assetDisplayInfoOut.assetPrecision + ), + let amountInDecimal = Decimal.fromSubstrateAmount( + params.amountIn, + precision: params.assetDisplayInfoIn.assetPrecision + ), + amountInDecimal != 0 else { + return "" + } + let difference = amountOutDecimal / amountInDecimal + + return balanceViewModelFactoryFacade.rateFromValue( + mainSymbol: params.assetDisplayInfoIn.symbol, + targetAssetInfo: params.assetDisplayInfoOut, + value: difference + ).value(for: locale) + } + private func createContentViewModel( from data: OperationDetailsModel.OperationData, chainAsset: ChainAsset, @@ -260,6 +374,9 @@ final class OperationDetailsViewModelFactory { locale: locale ) return .poolSlash(viewModel) + case let .swap(model): + let viewModel = createSwapViewModel(from: model, locale: locale) + return .swap(viewModel) } } } diff --git a/novawallet/Modules/OperationDetails/ViewModel/OperationSwapViewModel.swift b/novawallet/Modules/OperationDetails/ViewModel/OperationSwapViewModel.swift new file mode 100644 index 0000000000..cf5ab7b9d4 --- /dev/null +++ b/novawallet/Modules/OperationDetails/ViewModel/OperationSwapViewModel.swift @@ -0,0 +1,11 @@ +import Foundation + +struct OperationSwapViewModel { + let direction: AssetConversion.Direction + let assetIn: SwapAssetAmountViewModel + let assetOut: SwapAssetAmountViewModel + let rate: String + let fee: BalanceViewModelProtocol + let wallet: WalletAccountViewModel + let transactionHash: String +} diff --git a/novawallet/Modules/Settings/SettingsPresenter.swift b/novawallet/Modules/Settings/SettingsPresenter.swift index ded2735930..b3bb994e6d 100644 --- a/novawallet/Modules/Settings/SettingsPresenter.swift +++ b/novawallet/Modules/Settings/SettingsPresenter.swift @@ -198,6 +198,8 @@ extension SettingsPresenter: SettingsPresenterProtocol { } else { wireframe.showScan(from: view, delegate: self) } + case .wiki: + show(url: config.wikiURL) } } diff --git a/novawallet/Modules/Settings/ViewModel/SettingsRow.swift b/novawallet/Modules/Settings/ViewModel/SettingsRow.swift index 77cc2747f4..4c1af7cd19 100644 --- a/novawallet/Modules/Settings/ViewModel/SettingsRow.swift +++ b/novawallet/Modules/Settings/ViewModel/SettingsRow.swift @@ -18,6 +18,7 @@ enum SettingsRow { case terms case privacyPolicy case walletConnect + case wiki } extension SettingsRow { @@ -56,6 +57,8 @@ extension SettingsRow { return R.string.localizable.aboutPrivacy(preferredLanguages: locale.rLanguages) case .walletConnect: return R.string.localizable.commonWalletConnect(preferredLanguages: locale.rLanguages) + case .wiki: + return R.string.localizable.settingsWiki(preferredLanguages: locale.rLanguages) } } @@ -93,6 +96,8 @@ extension SettingsRow { return R.image.iconTerms()! case .walletConnect: return R.image.iconWalletConnect()! + case .wiki: + return R.image.iconWiki()! } } } diff --git a/novawallet/Modules/Settings/ViewModel/SettingsViewModelFactory.swift b/novawallet/Modules/Settings/ViewModel/SettingsViewModelFactory.swift index 2d5c4b8063..52311765cd 100644 --- a/novawallet/Modules/Settings/ViewModel/SettingsViewModelFactory.swift +++ b/novawallet/Modules/Settings/ViewModel/SettingsViewModelFactory.swift @@ -63,6 +63,7 @@ final class SettingsViewModelFactory: SettingsViewModelFactoryProtocol { ]), (.support, [ createCommonViewViewModel(row: .rateUs, locale: locale), + createCommonViewViewModel(row: .wiki, locale: locale), createCommonViewViewModel(row: .email, locale: locale) ]), (.about, [ diff --git a/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainConsensusStateDepending.swift b/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainConsensusStateDepending.swift index 64b18d3b39..c5df8bbe6a 100644 --- a/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainConsensusStateDepending.swift +++ b/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainConsensusStateDepending.swift @@ -4,7 +4,8 @@ import SubstrateSdk protocol RelaychainConsensusStateDepending { func createNetworkInfoOperationFactory( - for durationFactory: StakingDurationOperationFactoryProtocol, + for chain: ChainModel, + durationFactory: StakingDurationOperationFactoryProtocol, operationQueue: OperationQueue ) -> NetworkStakingInfoOperationFactoryProtocol @@ -22,10 +23,12 @@ protocol RelaychainConsensusStateDepending { final class RelaychainConsensusStateDependingFactory: RelaychainConsensusStateDepending { func createNetworkInfoOperationFactory( - for durationFactory: StakingDurationOperationFactoryProtocol, + for chain: ChainModel, + durationFactory: StakingDurationOperationFactoryProtocol, operationQueue: OperationQueue ) -> NetworkStakingInfoOperationFactoryProtocol { let votersInfoOperationFactory = VotersInfoOperationFactory( + chain: chain, operationManager: OperationManager(operationQueue: operationQueue) ) diff --git a/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainStakingSharedState.swift b/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainStakingSharedState.swift index c870e70fb9..548f85a656 100644 --- a/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainStakingSharedState.swift +++ b/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainStakingSharedState.swift @@ -123,7 +123,8 @@ final class RelaychainStakingSharedState: RelaychainStakingSharedStateProtocol { ) return consensusDependingFactory.createNetworkInfoOperationFactory( - for: durationFactory, + for: stakingOption.chainAsset.chain, + durationFactory: durationFactory, operationQueue: operationQueue ) } diff --git a/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainStartStakingState.swift b/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainStartStakingState.swift index bb0c17ba75..848535598f 100644 --- a/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainStartStakingState.swift +++ b/novawallet/Modules/Staking/Model/StakingSharedState/RelaychainStartStakingState.swift @@ -211,7 +211,8 @@ final class RelaychainStartStakingState: RelaychainStartStakingStateProtocol { ) return consensusDependingFactory.createNetworkInfoOperationFactory( - for: durationFactory, + for: chainAsset.chain, + durationFactory: durationFactory, operationQueue: operationQueue ) } diff --git a/novawallet/Modules/Staking/NominationPools/Selection/View/StakingPoolView.swift b/novawallet/Modules/Staking/NominationPools/Selection/View/StakingPoolView.swift index 2f3c68fd76..372b86a4c0 100644 --- a/novawallet/Modules/Staking/NominationPools/Selection/View/StakingPoolView.swift +++ b/novawallet/Modules/Staking/NominationPools/Selection/View/StakingPoolView.swift @@ -42,7 +42,7 @@ final class StakingPoolView: GenericTitleValueView ) -> BaseOperation { + if let maxElectingVoter = chain.stakingMaxElectingVoters { + return BaseOperation.createWithResult(maxElectingVoter) + } + let valueOperation = PrimitiveConstantOperation( path: ElectionProviderMultiPhase.maxElectingVoters, fallbackValue: UInt32.max diff --git a/novawallet/Modules/Staking/Parachain/ParaStkSelectCollators/View/CollatorSelectionCell.swift b/novawallet/Modules/Staking/Parachain/ParaStkSelectCollators/View/CollatorSelectionCell.swift index 8361bd00ea..92a3e2e278 100644 --- a/novawallet/Modules/Staking/Parachain/ParaStkSelectCollators/View/CollatorSelectionCell.swift +++ b/novawallet/Modules/Staking/Parachain/ParaStkSelectCollators/View/CollatorSelectionCell.swift @@ -55,7 +55,7 @@ class CollatorSelectionCell: UITableViewCell { let infoButton: UIButton = { let button = UIButton() - let icon = R.image.iconInfoFilled()?.tinted(with: R.color.colorIconSecondary()!) + let icon = R.image.iconInfoFilled() button.setImage(icon, for: .normal) return button }() diff --git a/novawallet/Modules/Staking/SelectValidatorsFlow/CustomValidatorList/View/CustomValidatorCell.swift b/novawallet/Modules/Staking/SelectValidatorsFlow/CustomValidatorList/View/CustomValidatorCell.swift index 5d26558c73..741d04be2a 100644 --- a/novawallet/Modules/Staking/SelectValidatorsFlow/CustomValidatorList/View/CustomValidatorCell.swift +++ b/novawallet/Modules/Staking/SelectValidatorsFlow/CustomValidatorList/View/CustomValidatorCell.swift @@ -47,7 +47,7 @@ class CustomValidatorCell: UITableViewCell { let infoButton: UIButton = { let button = UIButton() - let icon = R.image.iconInfoFilled()?.tinted(with: R.color.colorIconSecondary()!) + let icon = R.image.iconInfoFilled() button.setImage(icon, for: .normal) return button }() diff --git a/novawallet/Modules/Staking/SelectValidatorsFlow/SelectedValidatorList/View/SelectedValidatorCell.swift b/novawallet/Modules/Staking/SelectValidatorsFlow/SelectedValidatorList/View/SelectedValidatorCell.swift index d501ac4e4d..0ffca4636a 100644 --- a/novawallet/Modules/Staking/SelectValidatorsFlow/SelectedValidatorList/View/SelectedValidatorCell.swift +++ b/novawallet/Modules/Staking/SelectValidatorsFlow/SelectedValidatorList/View/SelectedValidatorCell.swift @@ -37,7 +37,7 @@ class SelectedValidatorCell: UITableViewCell { let infoImageView: UIImageView = { let imageView = UIImageView() - imageView.image = R.image.iconInfoFilled()?.tinted(with: R.color.colorIconSecondary()!) + imageView.image = R.image.iconInfoFilled()! imageView.setContentHuggingPriority(.defaultHigh, for: .horizontal) imageView.setContentHuggingPriority(.defaultHigh, for: .vertical) return imageView diff --git a/novawallet/Modules/Staking/SelectValidatorsFlow/YourValidatorList/View/YourValidatorTableCell.swift b/novawallet/Modules/Staking/SelectValidatorsFlow/YourValidatorList/View/YourValidatorTableCell.swift index d8a01a5b2d..fc201049b6 100644 --- a/novawallet/Modules/Staking/SelectValidatorsFlow/YourValidatorList/View/YourValidatorTableCell.swift +++ b/novawallet/Modules/Staking/SelectValidatorsFlow/YourValidatorList/View/YourValidatorTableCell.swift @@ -44,7 +44,7 @@ class YourValidatorTableCell: UITableViewCell { let infoImageView: UIImageView = { let imageView = UIImageView() - imageView.image = R.image.iconInfoFilled()?.tinted(with: R.color.colorIconSecondary()!) + imageView.image = R.image.iconInfoFilled() return imageView }() diff --git a/novawallet/Modules/Staking/StakingMain/View/RewardEstimationView.swift b/novawallet/Modules/Staking/StakingMain/View/RewardEstimationView.swift deleted file mode 100644 index 8ae09cf9bd..0000000000 --- a/novawallet/Modules/Staking/StakingMain/View/RewardEstimationView.swift +++ /dev/null @@ -1,337 +0,0 @@ -import UIKit - -import SoraFoundation -import SoraUI - -protocol RewardEstimationViewDelegate: AnyObject { - func rewardEstimationDidStartAction(_ view: RewardEstimationView) - func rewardEstimationDidRequestInfo(_ view: RewardEstimationView) -} - -final class RewardEstimationView: LocalizableView { - let backgroundView: BlockBackgroundView = { - let view = BlockBackgroundView() - return view - }() - - let titleLabel: UILabel = { - let label = UILabel() - label.textColor = R.color.colorTextPrimary() - label.font = .regularSubheadline - return label - }() - - let monthlyTitleLabel: UILabel = { - let label = UILabel() - label.textColor = R.color.colorTextSecondary() - label.font = .regularFootnote - return label - }() - - let monthlyValueLabel: UILabel = { - let label = UILabel() - label.textColor = R.color.colorTextPositive() - label.font = .title2 - return label - }() - - let yearlyTitleLabel: UILabel = { - let label = UILabel() - label.textColor = R.color.colorTextSecondary() - label.font = .regularFootnote - return label - }() - - let yearlyValueLabel: UILabel = { - let label = UILabel() - label.textColor = R.color.colorTextPositive() - label.font = .title2 - return label - }() - - let mainButton: TriangularedButton = { - let button = TriangularedButton() - button.applyDefaultStyle() - return button - }() - - let infoButton: RoundedButton = { - let button = RoundedButton() - button.applyIconStyle() - button.imageWithTitleView?.iconImage = R.image.iconInfo()?.withRenderingMode(.alwaysTemplate) - button.tintColor = R.color.colorIconSecondary()! - return button - }() - - private var skeletonView: SkrullableView? - - var actionTitle: LocalizableResource = LocalizableResource { locale in - R.string.localizable.stakingStartTitle(preferredLanguages: locale.rLanguages) - } { - didSet { - applyActionTitle() - } - } - - weak var delegate: RewardEstimationViewDelegate? - - var locale = Locale.current { - didSet { - applyLocalization() - - if widgetViewModel != nil { - applyWidgetViewModel() - } - } - } - - private var widgetViewModel: StakingEstimationViewModel? - - override init(frame: CGRect) { - super.init(frame: frame) - - setupLayout() - - mainButton.addTarget( - self, - action: #selector(actionMainTouchUpInside), - for: .touchUpInside - ) - - infoButton.addTarget( - self, - action: #selector(actionInfoTouchUpInside), - for: .touchUpInside - ) - } - - override var intrinsicContentSize: CGSize { - CGSize(width: UIView.noIntrinsicMetric, height: 202.0) - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - - if skeletonView != nil { - setupSkeleton() - } - } - - func bind(viewModel: StakingEstimationViewModel) { - widgetViewModel = viewModel - applyWidgetViewModel() - } - - private func setupLayout() { - addSubview(backgroundView) - backgroundView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - - addSubview(infoButton) - infoButton.snp.makeConstraints { make in - make.trailing.equalToSuperview() - make.top.equalToSuperview() - make.width.equalTo(56.0) - make.height.equalTo(48.0) - } - - addSubview(titleLabel) - titleLabel.snp.makeConstraints { make in - make.leading.equalToSuperview().inset(16.0) - make.top.equalToSuperview().inset(14.0) - } - - addSubview(mainButton) - mainButton.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(UIConstants.horizontalInset) - make.bottom.equalToSuperview().inset(24.0) - make.height.equalTo(UIConstants.actionHeight) - } - - addSubview(monthlyTitleLabel) - monthlyTitleLabel.snp.makeConstraints { make in - make.leading.greaterThanOrEqualToSuperview().inset(16) - make.trailing.lessThanOrEqualTo(self.snp.centerX).offset(-16.0) - make.centerX.equalToSuperview().multipliedBy(0.5) - make.bottom.equalTo(mainButton.snp.top).offset(-24.0) - } - - addSubview(yearlyTitleLabel) - yearlyTitleLabel.snp.makeConstraints { make in - make.leading.greaterThanOrEqualTo(self.snp.centerX).offset(16.0) - make.trailing.lessThanOrEqualToSuperview().offset(-16.0) - make.centerX.equalToSuperview().multipliedBy(1.5) - make.bottom.equalTo(mainButton.snp.top).offset(-24.0) - } - - addSubview(monthlyValueLabel) - monthlyValueLabel.snp.makeConstraints { make in - make.leading.greaterThanOrEqualToSuperview().inset(16) - make.trailing.lessThanOrEqualTo(self.snp.centerX).offset(-16.0) - make.centerX.equalToSuperview().multipliedBy(0.5) - make.bottom.equalTo(monthlyTitleLabel.snp.top).offset(-2.0) - } - - addSubview(yearlyValueLabel) - yearlyValueLabel.snp.makeConstraints { make in - make.leading.greaterThanOrEqualTo(self.snp.centerX).offset(16.0) - make.trailing.lessThanOrEqualToSuperview().offset(-16.0) - make.centerX.equalToSuperview().multipliedBy(1.5) - make.bottom.equalTo(yearlyTitleLabel.snp.top).offset(-2.0) - } - } - - private func applyWidgetViewModel() { - let tokenSymbol = widgetViewModel?.tokenSymbol ?? "" - titleLabel.text = R.string.localizable.stakingEstimateEarningTitle_v190( - tokenSymbol.uppercased(), - preferredLanguages: locale.rLanguages - ) - - if let viewModel = widgetViewModel?.reward?.value(for: locale) { - stopLoadingIfNeeded() - - monthlyValueLabel.text = viewModel.monthly - yearlyValueLabel.text = viewModel.yearly - } else { - startLoadingIfNeeded() - } - } - - private func applyLocalization() { - let languages = locale.rLanguages - - monthlyTitleLabel.text = R.string.localizable - .stakingMonthPeriodTitle(preferredLanguages: languages) - - yearlyTitleLabel.text = R.string.localizable - .stakingYearPeriodTitle(preferredLanguages: languages) - - applyActionTitle() - } - - private func applyActionTitle() { - let title = actionTitle.value(for: locale) - mainButton.imageWithTitleView?.title = title - mainButton.invalidateLayout() - } - - func startLoadingIfNeeded() { - guard skeletonView == nil else { - return - } - - monthlyValueLabel.alpha = 0.0 - yearlyValueLabel.alpha = 0.0 - - setupSkeleton() - } - - func stopLoadingIfNeeded() { - guard skeletonView != nil else { - return - } - - skeletonView?.stopSkrulling() - skeletonView?.removeFromSuperview() - skeletonView = nil - - monthlyValueLabel.alpha = 1.0 - yearlyValueLabel.alpha = 1.0 - } - - private func setupSkeleton() { - let spaceSize = frame.size - - guard spaceSize.width > 0, spaceSize.height > 0 else { - return - } - - let builder = Skrull( - size: spaceSize, - decorations: [], - skeletons: createSkeletons(for: spaceSize) - ) - - let currentSkeletonView: SkrullableView? - - if let skeletonView = skeletonView { - currentSkeletonView = skeletonView - builder.updateSkeletons(in: skeletonView) - } else { - let view = builder - .fillSkeletonStart(R.color.colorSkeletonStart()!) - .fillSkeletonEnd(color: R.color.colorSkeletonEnd()!) - .build() - view.autoresizingMask = [] - insertSubview(view, aboveSubview: backgroundView) - - skeletonView = view - - view.startSkrulling() - - currentSkeletonView = view - } - - currentSkeletonView?.frame = CGRect(origin: .zero, size: spaceSize) - } - - private func createSkeletons(for spaceSize: CGSize) -> [Skeletonable] { - let bigRowSize = CGSize(width: 72.0, height: 12.0) - - let offsetY = 8.0 - let monthlyOffsetX = monthlyTitleLabel.intrinsicContentSize.width / 2.0 - bigRowSize.width / 2.0 - let yearlyOffsetX = yearlyTitleLabel.intrinsicContentSize.width / 2.0 - bigRowSize.width / 2.0 - - return [ - SingleSkeleton.createRow( - above: monthlyTitleLabel, - containerView: self, - spaceSize: spaceSize, - offset: CGPoint(x: monthlyOffsetX, y: offsetY), - size: bigRowSize - ), - - SingleSkeleton.createRow( - above: yearlyTitleLabel, - containerView: self, - spaceSize: spaceSize, - offset: CGPoint(x: yearlyOffsetX, y: offsetY), - size: bigRowSize - ) - ] - } - - @objc private func actionMainTouchUpInside() { - delegate?.rewardEstimationDidStartAction(self) - } - - @objc private func actionInfoTouchUpInside() { - delegate?.rewardEstimationDidRequestInfo(self) - } -} - -extension RewardEstimationView: SkeletonLoadable { - func didDisappearSkeleton() { - skeletonView?.stopSkrulling() - } - - func didAppearSkeleton() { - skeletonView?.stopSkrulling() - skeletonView?.startSkrulling() - } - - func didUpdateSkeletonLayout() { - guard skeletonView != nil else { - return - } - - setupSkeleton() - } -} diff --git a/novawallet/Modules/Swaps/Base/Model/AssetHubFeeModelBuilder.swift b/novawallet/Modules/Swaps/Base/Model/AssetHubFeeModelBuilder.swift new file mode 100644 index 0000000000..92b3e72a7d --- /dev/null +++ b/novawallet/Modules/Swaps/Base/Model/AssetHubFeeModelBuilder.swift @@ -0,0 +1,61 @@ +import Foundation +typealias FeeChainAssetId = ChainAssetId + +final class AssetHubFeeModelBuilder { + typealias ResultClosure = (AssetConversion.FeeModel, AssetConversion.CallArgs, FeeChainAssetId?) -> Void + let utilityChainAssetId: ChainAssetId + let resultClosure: ResultClosure + + private(set) var feeAsset: ChainAsset? + + private var recepientUtilityBalance: AssetBalance? + private var feeModel: AssetConversion.FeeModel? + private var callArgs: AssetConversion.CallArgs? + + init( + utilityChainAssetId: ChainAssetId, + resultClosure: @escaping ResultClosure + ) { + self.utilityChainAssetId = utilityChainAssetId + self.resultClosure = resultClosure + } + + private func provideResult() { + guard + let balance = recepientUtilityBalance, + let feeModel = feeModel, + let callArgs = callArgs else { + return + } + + let resultModel: AssetConversion.FeeModel + + if balance.freeInPlank >= feeModel.totalFee.nativeAmount { + // we have enough tokens for ed - need to exchange only network fee + let networkFee = feeModel.networkFee + resultModel = .init(totalFee: networkFee, networkFee: networkFee) + } else { + resultModel = feeModel + } + + resultClosure(resultModel, callArgs, feeAsset?.chainAssetId) + } +} + +extension AssetHubFeeModelBuilder { + func apply(recepientUtilityBalance: AssetBalance) { + self.recepientUtilityBalance = recepientUtilityBalance + provideResult() + } + + func apply(feeModel: AssetConversion.FeeModel, args: AssetConversion.CallArgs) { + self.feeModel = feeModel + callArgs = args + + provideResult() + } + + func apply(feeAsset: ChainAsset) { + self.feeAsset = feeAsset + } +} diff --git a/novawallet/Modules/Swaps/Base/Model/SlippageConfig.swift b/novawallet/Modules/Swaps/Base/Model/SlippageConfig.swift new file mode 100644 index 0000000000..e3388280e2 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/Model/SlippageConfig.swift @@ -0,0 +1,27 @@ +import Foundation + +struct SlippageConfig { + let defaultSlippage: BigRational + let slippageTips: [BigRational] + let minAvailableSlippage: BigRational + let maxAvailableSlippage: BigRational + let smallSlippage: BigRational + let bigSlippage: BigRational +} + +extension SlippageConfig { + static var defaultConfig: SlippageConfig { + .init( + defaultSlippage: BigRational(numerator: 5, denominator: 1000), + slippageTips: [ + BigRational(numerator: 1, denominator: 1000), + BigRational(numerator: 5, denominator: 1000), + BigRational(numerator: 1, denominator: 100) + ], + minAvailableSlippage: BigRational(numerator: 1, denominator: 10000), + maxAvailableSlippage: BigRational(numerator: 50, denominator: 100), + smallSlippage: BigRational(numerator: 5, denominator: 10000), + bigSlippage: BigRational(numerator: 1, denominator: 100) + ) + } +} diff --git a/novawallet/Modules/Swaps/Base/Model/SwapBaseViewModelFactory.swift b/novawallet/Modules/Swaps/Base/Model/SwapBaseViewModelFactory.swift new file mode 100644 index 0000000000..b0307193e5 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/Model/SwapBaseViewModelFactory.swift @@ -0,0 +1,159 @@ +import Foundation +import BigInt +import SoraFoundation + +struct RateParams { + let assetDisplayInfoIn: AssetBalanceDisplayInfo + let assetDisplayInfoOut: AssetBalanceDisplayInfo + let amountIn: BigUInt + let amountOut: BigUInt +} + +protocol SwapBaseViewModelFactoryProtocol { + func rateViewModel(from params: RateParams, locale: Locale) -> String + + func priceDifferenceViewModel( + rateParams: RateParams, + priceIn: PriceData?, + priceOut: PriceData?, + locale: Locale + ) -> DifferenceViewModel? + + func minimalBalanceSwapForFeeMessage( + for networkFeeAddition: AssetConversion.AmountWithNative, + feeChainAsset: ChainAsset, + utilityChainAsset: ChainAsset, + utilityPriceData: PriceData?, + locale: Locale + ) -> String +} + +class SwapBaseViewModelFactory { + let balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol + let percentForamatter: LocalizableResource + let priceDifferenceConfig: SwapPriceDifferenceConfig + + init( + balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol, + percentForamatter: LocalizableResource, + priceDifferenceConfig: SwapPriceDifferenceConfig + ) { + self.balanceViewModelFactoryFacade = balanceViewModelFactoryFacade + self.percentForamatter = percentForamatter + self.priceDifferenceConfig = priceDifferenceConfig + } + + func formatPriceDifference(amount: Decimal, locale: Locale) -> String { + percentForamatter.value(for: locale).stringFromDecimal(amount) ?? "" + } +} + +extension SwapBaseViewModelFactory: SwapBaseViewModelFactoryProtocol { + func rateViewModel(from params: RateParams, locale: Locale) -> String { + guard + let rate = Decimal.rateFromSubstrate( + amount1: params.amountIn, + amount2: params.amountOut, + precision1: params.assetDisplayInfoIn.assetPrecision, + precision2: params.assetDisplayInfoOut.assetPrecision + ) else { + return "" + } + + let amountIn = balanceViewModelFactoryFacade.amountFromValue( + targetAssetInfo: params.assetDisplayInfoIn, + value: 1 + ).value(for: locale) + let amountOut = balanceViewModelFactoryFacade.amountFromValue( + targetAssetInfo: params.assetDisplayInfoOut, + value: rate + ).value(for: locale) + + return amountIn.estimatedEqual(to: amountOut) + } + + func minimalBalanceSwapForFeeMessage( + for networkFeeAddition: AssetConversion.AmountWithNative, + feeChainAsset: ChainAsset, + utilityChainAsset: ChainAsset, + utilityPriceData: PriceData?, + locale: Locale + ) -> String { + let targetAmount = balanceViewModelFactoryFacade.amountFromValue( + targetAssetInfo: feeChainAsset.assetDisplayInfo, + value: networkFeeAddition.targetAmount.decimal(precision: feeChainAsset.asset.precision) + ).value(for: locale) + + let nativeAmountDecimal = networkFeeAddition.nativeAmount.decimal(precision: utilityChainAsset.asset.precision) + let nativeAmountWithoutPrice = balanceViewModelFactoryFacade.amountFromValue( + targetAssetInfo: utilityChainAsset.assetDisplayInfo, + value: nativeAmountDecimal + ).value(for: locale) + + let nativeAmount: String + + if let priceData = utilityPriceData { + let price = balanceViewModelFactoryFacade.priceFromAmount( + targetAssetInfo: utilityChainAsset.assetDisplayInfo, + amount: nativeAmountDecimal, + priceData: priceData + ).value(for: locale) + + nativeAmount = "\(nativeAmountWithoutPrice) \(price.inParenthesis())" + } else { + nativeAmount = nativeAmountWithoutPrice + } + + return R.string.localizable.swapsPayAssetFeeEdMessage( + feeChainAsset.asset.symbol, + targetAmount, + nativeAmount, + utilityChainAsset.asset.symbol, + preferredLanguages: locale.rLanguages + ) + } + + func priceDifferenceViewModel( + rateParams params: RateParams, + priceIn: PriceData?, + priceOut: PriceData?, + locale: Locale + ) -> DifferenceViewModel? { + guard let priceIn = priceIn?.decimalRate, let priceOut = priceOut?.decimalRate else { + return nil + } + + guard + let amountOutDecimal = Decimal.fromSubstrateAmount( + params.amountOut, + precision: params.assetDisplayInfoOut.assetPrecision + ), + let amountInDecimal = Decimal.fromSubstrateAmount( + params.amountIn, + precision: params.assetDisplayInfoIn.assetPrecision + ) else { + return nil + } + + let amountPriceIn = amountInDecimal * priceIn + let amountPriceOut = amountOutDecimal * priceOut + + guard amountPriceIn > 0, amountPriceOut > 0, amountPriceIn > amountPriceOut else { + return nil + } + + let diff = abs(amountPriceIn - amountPriceOut) / amountPriceIn + let diffString = formatPriceDifference(amount: diff, locale: locale) + + switch diff { + case _ where diff >= priceDifferenceConfig.high: + return .init(details: diffString, attention: .high) + case priceDifferenceConfig.medium ... priceDifferenceConfig.high: + return .init(details: diffString, attention: .medium) + case priceDifferenceConfig.low ... priceDifferenceConfig.medium: + return .init(details: diffString, attention: .low) + default: + return nil + } + } +} diff --git a/novawallet/Modules/Swaps/Base/Model/SwapMaxModel.swift b/novawallet/Modules/Swaps/Base/Model/SwapMaxModel.swift new file mode 100644 index 0000000000..57be6c2f90 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/Model/SwapMaxModel.swift @@ -0,0 +1,70 @@ +import Foundation + +struct SwapMaxModel { + let payChainAsset: ChainAsset? + let feeChainAsset: ChainAsset? + let balance: AssetBalance? + let feeModel: AssetConversion.FeeModel? + let payAssetExistense: AssetBalanceExistence? + let receiveAssetExistense: AssetBalanceExistence? + let accountInfo: AccountInfo? + + func minBalanceCoveredByFrozen(in balance: AssetBalance) -> Bool { + let minBalance = payAssetExistense?.minBalance ?? 0 + + return balance.transferable + minBalance <= balance.freeInPlank + } + + var shouldKeepMinBalance: Bool { + guard payChainAsset?.isUtilityAsset == true else { + return false + } + + guard let receiveAssetExistense = receiveAssetExistense else { + return false + } + + let hasConsumers = (accountInfo?.hasConsumers ?? false) + + return (!receiveAssetExistense.isSelfSufficient || hasConsumers) + } + + private func calculateForNativeAsset(_ payChainAsset: ChainAsset, balance: AssetBalance) -> Decimal { + var maxAmount = balance.transferable + + if shouldKeepMinBalance, !minBalanceCoveredByFrozen(in: balance) { + let minBalance = payAssetExistense?.minBalance ?? 0 + maxAmount = maxAmount.saturatingSub(minBalance) + } + + if let feeModel = feeModel { + let fee = feeModel.totalFee.targetAmount + maxAmount = maxAmount.saturatingSub(fee) + } + + return maxAmount.decimal(precision: payChainAsset.asset.precision) + } + + private func calculateForCustomAsset(_ payChainAsset: ChainAsset, balance: AssetBalance) -> Decimal { + guard let feeModel = feeModel, payChainAsset.chainAssetId == feeChainAsset?.chainAssetId else { + return balance.transferable.decimal(precision: payChainAsset.asset.precision) + } + + let fee = feeModel.totalFee.targetAmount + let maxAmount = balance.transferable.saturatingSub(fee) + + return maxAmount.decimal(precision: payChainAsset.asset.precision) + } + + func calculate() -> Decimal { + guard let payChainAsset = payChainAsset, let balance = balance else { + return 0 + } + + if payChainAsset.isUtilityAsset { + return calculateForNativeAsset(payChainAsset, balance: balance) + } else { + return calculateForCustomAsset(payChainAsset, balance: balance) + } + } +} diff --git a/novawallet/Modules/Swaps/Base/Model/SwapPriceDifferenceConfig.swift b/novawallet/Modules/Swaps/Base/Model/SwapPriceDifferenceConfig.swift new file mode 100644 index 0000000000..a401b8fc36 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/Model/SwapPriceDifferenceConfig.swift @@ -0,0 +1,17 @@ +import Foundation + +struct SwapPriceDifferenceConfig { + let high: Decimal + let medium: Decimal + let low: Decimal +} + +extension SwapPriceDifferenceConfig { + static var defaultConfig: SwapPriceDifferenceConfig { + .init( + high: 0.15, + medium: 0.05, + low: 0.01 + ) + } +} diff --git a/novawallet/Modules/Swaps/Base/ShortTextInfoPresentableExtensions.swift b/novawallet/Modules/Swaps/Base/ShortTextInfoPresentableExtensions.swift new file mode 100644 index 0000000000..e00895e039 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/ShortTextInfoPresentableExtensions.swift @@ -0,0 +1,53 @@ +import SoraFoundation + +extension ShortTextInfoPresentable { + func showFeeInfo(from view: ControllerBackedProtocol?) { + let title = LocalizableResource { + R.string.localizable.commonNetworkFee( + preferredLanguages: $0.rLanguages + ) + } + let details = LocalizableResource { + R.string.localizable.swapsNetworkFeeDescription( + preferredLanguages: $0.rLanguages + ) + } + showInfo( + from: view, + title: title, + details: details + ) + } + + func showRateInfo(from view: ControllerBackedProtocol?) { + let title = LocalizableResource { + R.string.localizable.swapsSetupDetailsRate( + preferredLanguages: $0.rLanguages + ) + } + let details = LocalizableResource { + R.string.localizable.swapsRateDescription( + preferredLanguages: $0.rLanguages + ) + } + showInfo( + from: view, + title: title, + details: details + ) + } + + func showSlippageInfo(from view: ControllerBackedProtocol?) { + let title = LocalizableResource { + R.string.localizable.swapsSetupSlippage(preferredLanguages: $0.rLanguages) + } + let details = LocalizableResource { + R.string.localizable.swapsSetupSlippageDescription(preferredLanguages: $0.rLanguages) + } + showInfo( + from: view, + title: title, + details: details + ) + } +} diff --git a/novawallet/Modules/Swaps/Base/SlippageBounds.swift b/novawallet/Modules/Swaps/Base/SlippageBounds.swift new file mode 100644 index 0000000000..394e7ebd15 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/SlippageBounds.swift @@ -0,0 +1,60 @@ +import Foundation + +struct SlippageBounds { + let restriction: BoundValue + let recommendation: BoundValue + + struct BoundValue { + let lower: Decimal + let upper: Decimal + } + + init(config: SlippageConfig) { + restriction = .init( + lower: config.minAvailableSlippage.decimalOrZeroValue, + upper: config.maxAvailableSlippage.decimalOrZeroValue + ) + + recommendation = .init( + lower: config.smallSlippage.decimalOrZeroValue, + upper: config.bigSlippage.decimalOrZeroValue + ) + } +} + +extension SlippageBounds { + func warning(for value: Decimal?, locale: Locale) -> String? { + guard let value = value, value > 0 else { + return nil + } + if value < recommendation.lower { + let warning = R.string.localizable.swapsSetupSlippageWarningLowAmount( + preferredLanguages: locale.rLanguages + ) + return warning + } else if value > recommendation.upper { + let warning = R.string.localizable.swapsSetupSlippageWarningHighAmount( + preferredLanguages: locale.rLanguages + ) + return warning + } else { + return nil + } + } + + func error(for value: Decimal?, stringAmountClosure: (Decimal) -> String, locale: Locale) -> String? { + if let value = value, + value < restriction.lower || value > restriction.upper { + let minAmountString = stringAmountClosure(restriction.lower) + let maxAmountString = stringAmountClosure(restriction.upper) + let error = R.string.localizable.swapsSetupSlippageErrorAmountBounds( + minAmountString, + maxAmountString, + preferredLanguages: locale.rLanguages + ) + return error + } else { + return nil + } + } +} diff --git a/novawallet/Modules/Swaps/Base/SwapBaseInteractor.swift b/novawallet/Modules/Swaps/Base/SwapBaseInteractor.swift new file mode 100644 index 0000000000..bdbe846460 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/SwapBaseInteractor.swift @@ -0,0 +1,372 @@ +import UIKit +import RobinHood +import BigInt + +class SwapBaseInteractor: AnyCancellableCleaning, AnyProviderAutoCleaning, SwapBaseInteractorInputProtocol { + weak var basePresenter: SwapBaseInteractorOutputProtocol? + let assetConversionAggregator: AssetConversionAggregationFactoryProtocol + let assetConversionFeeService: AssetConversionFeeServiceProtocol + let chainRegistry: ChainRegistryProtocol + let assetStorageFactory: AssetStorageInfoOperationFactoryProtocol + let priceLocalSubscriptionFactory: PriceProviderFactoryProtocol + let walletLocalSubscriptionFactory: WalletLocalSubscriptionFactoryProtocol + let generalLocalSubscriptionFactory: GeneralStorageSubscriptionFactoryProtocol + let currencyManager: CurrencyManagerProtocol + let selectedWallet: MetaAccountModel + let operationQueue: OperationQueue + + private var quoteCall = CancellableCallStore() + + private var priceProviders: [ChainAssetId: StreamableProvider] = [:] + private var assetBalanceProviders: [ChainAssetId: StreamableProvider] = [:] + private var feeModelBuilder: AssetHubFeeModelBuilder? + private var accountInfoProvider: AnyDataProvider? + + var currentChain: ChainModel? + + init( + assetConversionAggregator: AssetConversionAggregationFactoryProtocol, + assetConversionFeeService: AssetConversionFeeServiceProtocol, + chainRegistry: ChainRegistryProtocol, + assetStorageFactory: AssetStorageInfoOperationFactoryProtocol, + priceLocalSubscriptionFactory: PriceProviderFactoryProtocol, + walletLocalSubscriptionFactory: WalletLocalSubscriptionFactoryProtocol, + generalSubscriptionFactory: GeneralStorageSubscriptionFactoryProtocol, + currencyManager: CurrencyManagerProtocol, + selectedWallet: MetaAccountModel, + operationQueue: OperationQueue + ) { + self.assetConversionAggregator = assetConversionAggregator + self.assetConversionFeeService = assetConversionFeeService + self.chainRegistry = chainRegistry + self.assetStorageFactory = assetStorageFactory + self.priceLocalSubscriptionFactory = priceLocalSubscriptionFactory + self.walletLocalSubscriptionFactory = walletLocalSubscriptionFactory + generalLocalSubscriptionFactory = generalSubscriptionFactory + self.currencyManager = currencyManager + self.selectedWallet = selectedWallet + self.operationQueue = operationQueue + } + + deinit { + quoteCall.cancel() + } + + private func provideAssetBalanceExistense(for chainAsset: ChainAsset) { + guard let runtimeService = chainRegistry.getRuntimeProvider(for: chainAsset.chain.chainId) else { + let error = ChainRegistryError.runtimeMetadaUnavailable + basePresenter?.didReceive(baseError: .assetBalanceExistense(error, chainAsset)) + return + } + + let wrapper = assetStorageFactory.createAssetBalanceExistenceOperation( + chainId: chainAsset.chain.chainId, + asset: chainAsset.asset, + runtimeProvider: runtimeService, + operationQueue: operationQueue + ) + + execute( + wrapper: wrapper, + inOperationQueue: operationQueue, + runningCallbackIn: .main + ) { [weak self] result in + switch result { + case let .success(existense): + self?.basePresenter?.didReceiveAssetBalance( + existense: existense, + chainAssetId: chainAsset.chainAssetId + ) + case let .failure(error): + self?.basePresenter?.didReceive( + baseError: .assetBalanceExistense(error, chainAsset) + ) + } + } + } + + func updateFeeModelBuilder(for chain: ChainModel) { + guard + let utilityAsset = chain.utilityChainAsset(), + feeModelBuilder?.utilityChainAssetId != utilityAsset.chainAssetId else { + return + } + + feeModelBuilder = AssetHubFeeModelBuilder( + utilityChainAssetId: utilityAsset.chainAssetId + ) { [weak self] feeModel, callArgs, feeChainAssetId in + self?.basePresenter?.didReceive( + fee: feeModel, + transactionFeeId: callArgs.identifier, + feeChainAssetId: feeChainAssetId + ) + } + + assetBalanceProviders[utilityAsset.chainAssetId] = assetBalanceSubscription(chainAsset: utilityAsset) + } + + func updateChain(with newChain: ChainModel) { + let oldChainId = currentChain?.chainId + currentChain = newChain + + if newChain.chainId != oldChainId { + updateAccountInfoProvider(for: newChain) + } + } + + func updateAccountInfoProvider(for chain: ChainModel) { + clear(dataProvider: &accountInfoProvider) + + guard let accountId = selectedWallet.fetch(for: chain.accountRequest())?.accountId else { + return + } + + accountInfoProvider = subscribeAccountInfo(for: accountId, chainId: chain.chainId) + } + + func updateSubscriptions(activeChainAssets: Set) { + priceProviders = clear(providers: priceProviders, activeChainAssets: activeChainAssets) + assetBalanceProviders = clear(providers: assetBalanceProviders, activeChainAssets: activeChainAssets) + } + + func clear( + providers: [ChainAssetId: StreamableProvider], + activeChainAssets: Set + ) -> [ChainAssetId: StreamableProvider] { + providers.reduce(into: [ChainAssetId: StreamableProvider]()) { + if !activeChainAssets.contains($1.key) { + $1.value.removeObserver(self) + } else { + $0[$1.key] = $1.value + } + } + } + + func priceSubscription(chainAsset: ChainAsset) -> StreamableProvider? { + guard let priceId = chainAsset.asset.priceId else { + return nil + } + + return priceProviders[chainAsset.chainAssetId] ?? subscribeToPrice( + for: priceId, + currency: currencyManager.selectedCurrency + ) + } + + func assetBalanceSubscription(chainAsset: ChainAsset) -> StreamableProvider? { + guard let accountId = chainAccountResponse(for: chainAsset)?.accountId else { + return nil + } + let chainAssetId = chainAsset.chainAssetId + return assetBalanceProviders[chainAssetId] ?? subscribeToAssetBalanceProvider( + for: accountId, + chainId: chainAssetId.chainId, + assetId: chainAssetId.assetId + ) + } + + func quote(args: AssetConversion.QuoteArgs) { + quoteCall.cancel() + + guard let chain = currentChain else { + return + } + + let wrapper = assetConversionAggregator.createQuoteWrapper(for: chain, args: args) + + executeCancellable( + wrapper: wrapper, + inOperationQueue: operationQueue, + backingCallIn: quoteCall, + runningCallbackIn: .main + ) { [weak self] result in + switch result { + case let .success(quote): + self?.basePresenter?.didReceive(quote: quote, for: args) + case let .failure(error): + self?.basePresenter?.didReceive(baseError: .quote(error, args)) + } + } + } + + func fee(args: AssetConversion.CallArgs) { + guard let feeAsset = feeModelBuilder?.feeAsset else { + return + } + + assetConversionFeeService.calculate( + in: feeAsset, + callArgs: args, + runCompletionIn: .main + ) { [weak self] result in + switch result { + case let .success(feeModel): + self?.feeModelBuilder?.apply(feeModel: feeModel, args: args) + case let .failure(error): + self?.basePresenter?.didReceive( + baseError: .fetchFeeFailed(error, args.identifier, feeAsset.chainAssetId) + ) + } + } + } + + func chainAccountResponse(for chainAsset: ChainAsset) -> ChainAccountResponse? { + let metaChainAccountResponse = selectedWallet.fetchMetaChainAccount(for: chainAsset.chain.accountRequest()) + return metaChainAccountResponse?.chainAccount + } + + func set(receiveChainAsset chainAsset: ChainAsset) { + updateChain(with: chainAsset.chain) + + updateFeeModelBuilder(for: chainAsset.chain) + + provideAssetBalanceExistense(for: chainAsset) + + priceProviders[chainAsset.chainAssetId] = priceSubscription(chainAsset: chainAsset) + assetBalanceProviders[chainAsset.chainAssetId] = assetBalanceSubscription(chainAsset: chainAsset) + } + + func set(payChainAsset chainAsset: ChainAsset) { + updateChain(with: chainAsset.chain) + + updateFeeModelBuilder(for: chainAsset.chain) + + if let utilityAsset = chainAsset.chain.utilityChainAsset() { + feeModelBuilder?.apply(feeAsset: utilityAsset) + } + + provideAssetBalanceExistense(for: chainAsset) + + priceProviders[chainAsset.chainAssetId] = priceSubscription(chainAsset: chainAsset) + assetBalanceProviders[chainAsset.chainAssetId] = assetBalanceSubscription(chainAsset: chainAsset) + } + + func set(feeChainAsset chainAsset: ChainAsset) { + updateFeeModelBuilder(for: chainAsset.chain) + feeModelBuilder?.apply(feeAsset: chainAsset) + + provideAssetBalanceExistense(for: chainAsset) + + if let utilityAsset = chainAsset.chain.utilityChainAsset(), !chainAsset.isUtilityAsset { + provideAssetBalanceExistense(for: utilityAsset) + } + + priceProviders[chainAsset.chainAssetId] = priceSubscription(chainAsset: chainAsset) + assetBalanceProviders[chainAsset.chainAssetId] = assetBalanceSubscription(chainAsset: chainAsset) + } + + // MARK: - SwapBaseInteractorInputProtocol + + func setup() {} + + func calculateQuote(for args: AssetConversion.QuoteArgs) { + quote(args: args) + } + + func calculateFee( + args: AssetConversion.CallArgs + ) { + fee(args: args) + } + + func retryAssetBalanceSubscription(for chainAsset: ChainAsset) { + clear(streamableProvider: &assetBalanceProviders[chainAsset.chainAssetId]) + assetBalanceProviders[chainAsset.chainAssetId] = assetBalanceSubscription(chainAsset: chainAsset) + } + + func remakePriceSubscription(for chainAsset: ChainAsset) { + clear(streamableProvider: &priceProviders[chainAsset.chainAssetId]) + priceProviders[chainAsset.chainAssetId] = priceSubscription(chainAsset: chainAsset) + } + + func retryAssetBalanceExistenseFetch(for chainAsset: ChainAsset) { + provideAssetBalanceExistense(for: chainAsset) + } + + func retryAccountInfoSubscription() { + guard let chain = currentChain else { + return + } + + updateAccountInfoProvider(for: chain) + } + + func requestValidatingQuote( + for args: AssetConversion.QuoteArgs, + completion: @escaping (Result) -> Void + ) { + guard let chain = currentChain else { + completion(.failure(ChainRegistryError.connectionUnavailable)) + return + } + + let wrapper = assetConversionAggregator.createQuoteWrapper(for: chain, args: args) + + execute( + wrapper: wrapper, + inOperationQueue: operationQueue, + runningCallbackIn: .main, + callbackClosure: completion + ) + } + + // MARK: Overridable General Subscription Handlers + + func handleBlockNumber( + result _: Result, + chainId _: ChainModel.Id + ) {} +} + +extension SwapBaseInteractor: PriceLocalStorageSubscriber, PriceLocalSubscriptionHandler { + func handlePrice(result: Result, priceId: AssetModel.PriceId) { + switch result { + case let .success(priceData): + basePresenter?.didReceive(price: priceData, priceId: priceId) + case let .failure(error): + basePresenter?.didReceive(baseError: .price(error, priceId)) + } + } +} + +extension SwapBaseInteractor: WalletLocalStorageSubscriber, WalletLocalSubscriptionHandler { + func handleAssetBalance( + result: Result, + accountId: AccountId, + chainId: ChainModel.Id, + assetId: AssetModel.Id + ) { + let chainAssetId = ChainAssetId(chainId: chainId, assetId: assetId) + switch result { + case let .success(balance): + let balance = balance ?? .createZero( + for: .init(chainId: chainId, assetId: assetId), + accountId: accountId + ) + + if feeModelBuilder?.utilityChainAssetId == chainAssetId { + feeModelBuilder?.apply(recepientUtilityBalance: balance) + } + + basePresenter?.didReceive(balance: balance, for: chainAssetId) + + case let .failure(error): + basePresenter?.didReceive(baseError: .assetBalance(error, chainAssetId, accountId)) + } + } +} + +extension SwapBaseInteractor: GeneralLocalStorageSubscriber, GeneralLocalStorageHandler { + func handleAccountInfo( + result: Result, + accountId _: AccountId, + chainId: ChainModel.Id + ) { + switch result { + case let .success(accountInfo): + basePresenter?.didReceive(accountInfo: accountInfo, chainId: chainId) + case let .failure(error): + basePresenter?.didReceive(baseError: .accountInfo(error)) + } + } +} diff --git a/novawallet/Modules/Swaps/Base/SwapBasePresenter.swift b/novawallet/Modules/Swaps/Base/SwapBasePresenter.swift new file mode 100644 index 0000000000..937910472e --- /dev/null +++ b/novawallet/Modules/Swaps/Base/SwapBasePresenter.swift @@ -0,0 +1,367 @@ +import Foundation + +class SwapBasePresenter { + let logger: LoggerProtocol + let selectedWallet: MetaAccountModel + let dataValidatingFactory: SwapDataValidatorFactoryProtocol + + init( + selectedWallet: MetaAccountModel, + dataValidatingFactory: SwapDataValidatorFactoryProtocol, + logger: LoggerProtocol + ) { + self.selectedWallet = selectedWallet + self.dataValidatingFactory = dataValidatingFactory + self.logger = logger + } + + private(set) var balances: [ChainAssetId: AssetBalance] = [:] + + var payAssetBalance: AssetBalance? { + getPayChainAsset().flatMap { balances[$0.chainAssetId] } + } + + var feeAssetBalance: AssetBalance? { + getFeeChainAsset().flatMap { balances[$0.chainAssetId] } + } + + var receiveAssetBalance: AssetBalance? { + getReceiveChainAsset().flatMap { balances[$0.chainAssetId] } + } + + var utilityAssetBalance: AssetBalance? { + guard let utilityAssetId = getFeeChainAsset()?.chain.utilityChainAssetId() else { + return nil + } + + return balances[utilityAssetId] + } + + private(set) var prices: [ChainAssetId: PriceData] = [:] + + var payAssetPriceData: PriceData? { + getPayChainAsset().flatMap { prices[$0.chainAssetId] } + } + + var receiveAssetPriceData: PriceData? { + getReceiveChainAsset().flatMap { prices[$0.chainAssetId] } + } + + var feeAssetPriceData: PriceData? { + getFeeChainAsset().flatMap { prices[$0.chainAssetId] } + } + + var assetBalanceExistences: [ChainAssetId: AssetBalanceExistence] = [:] + + var payAssetBalanceExistense: AssetBalanceExistence? { + getPayChainAsset().flatMap { assetBalanceExistences[$0.chainAssetId] } + } + + var receiveAssetBalanceExistense: AssetBalanceExistence? { + getReceiveChainAsset().flatMap { assetBalanceExistences[$0.chainAssetId] } + } + + var feeAssetBalanceExistense: AssetBalanceExistence? { + getFeeChainAsset().flatMap { assetBalanceExistences[$0.chainAssetId] } + } + + var utilityAssetBalanceExistense: AssetBalanceExistence? { + getFeeChainAsset()?.chain.utilityChainAsset().flatMap { + assetBalanceExistences[$0.chainAssetId] + } + } + + var fee: AssetConversion.FeeModel? + var quoteResult: Result? + + var quote: AssetConversion.Quote? { + switch quoteResult { + case let .success(quote): + return quote + case .failure, .none: + return nil + } + } + + var accountInfo: AccountInfo? + + func getSwapModel() -> SwapModel? { + guard + let payChainAsset = getPayChainAsset(), + let receiveChainAsset = getReceiveChainAsset(), + let feeChainAsset = getFeeChainAsset(), + let quoteArgs = getQuoteArgs() else { + return nil + } + + return .init( + payChainAsset: payChainAsset, + receiveChainAsset: receiveChainAsset, + feeChainAsset: feeChainAsset, + spendingAmount: getSpendingInputAmount(), + payAssetBalance: payAssetBalance, + feeAssetBalance: feeAssetBalance, + receiveAssetBalance: receiveAssetBalance, + utilityAssetBalance: utilityAssetBalance, + payAssetExistense: payAssetBalanceExistense, + receiveAssetExistense: receiveAssetBalanceExistense, + feeAssetExistense: feeAssetBalanceExistense, + utilityAssetExistense: utilityAssetBalanceExistense, + feeModel: fee, + quoteArgs: quoteArgs, + quote: quote, + slippage: getSlippage(), + accountInfo: accountInfo + ) + } + + func getMaxModel() -> SwapMaxModel? { + .init( + payChainAsset: getPayChainAsset(), + feeChainAsset: getFeeChainAsset(), + balance: payAssetBalance, + feeModel: fee, + payAssetExistense: payAssetBalanceExistense, + receiveAssetExistense: receiveAssetBalanceExistense, + accountInfo: accountInfo + ) + } + + func getSpendingInputAmount() -> Decimal? { + fatalError("Must be implemented by parent class") + } + + func getQuoteArgs() -> AssetConversion.QuoteArgs? { + fatalError("Must be implemented by parent class") + } + + func getSlippage() -> BigRational { + fatalError("Must be implemented by parent class") + } + + func getPayChainAsset() -> ChainAsset? { + fatalError("Must be implemented by parent class") + } + + func getReceiveChainAsset() -> ChainAsset? { + fatalError("Must be implemented by parent class") + } + + func getFeeChainAsset() -> ChainAsset? { + fatalError("Must be implemented by parent class") + } + + func shouldHandleQuote(for _: AssetConversion.QuoteArgs?) -> Bool { + fatalError("Must be implemented by parent class") + } + + func shouldHandleFee(for _: TransactionFeeId, feeChainAssetId _: ChainAssetId?) -> Bool { + fatalError("Must be implemented by parent class") + } + + func estimateFee() { + fatalError("Must be implemented by parent class") + } + + func applySwapMax() { + fatalError("Must be implemented by parent class") + } + + func handleBaseError(_: SwapBaseError) {} + + func handleNewQuote(_: AssetConversion.Quote, for _: AssetConversion.QuoteArgs) {} + + func handleNewFee( + _: AssetConversion.FeeModel?, + transactionFeeId _: TransactionFeeId, + feeChainAssetId _: ChainAssetId? + ) {} + + func handleNewPrice(_: PriceData?, chainAssetId _: ChainAssetId) {} + + func handleNewBalance(_: AssetBalance?, for _: ChainAssetId) {} + + func handleNewBalanceExistense(_: AssetBalanceExistence, chainAssetId _: ChainAssetId) {} + + func handleNewAccountInfo(_: AccountInfo?, chainId _: ChainModel.Id) {} + + func handleBaseError( + _ error: SwapBaseError, + view: ControllerBackedProtocol?, + interactor: SwapBaseInteractorInputProtocol, + wireframe: SwapBaseWireframeProtocol, + locale: Locale + ) { + logger.error("Did receive base error: \(error)") + + switch error { + case let .quote(error, args): + guard shouldHandleQuote(for: args) else { + return + } + + quoteResult = .failure(error) + case let .fetchFeeFailed(_, id, feeChainAssetId): + guard shouldHandleFee(for: id, feeChainAssetId: feeChainAssetId) else { + return + } + + wireframe.presentRequestStatus(on: view, locale: locale) { [weak self] in + self?.estimateFee() + } + case let .price(_, priceId): + wireframe.presentRequestStatus(on: view, locale: locale) { [weak self] in + guard let self = self else { + return + } + [self.getPayChainAsset(), self.getReceiveChainAsset(), self.getFeeChainAsset()] + .compactMap { $0 } + .filter { $0.asset.priceId == priceId } + .forEach(interactor.remakePriceSubscription) + } + case let .assetBalance(_, chainAssetId, _): + wireframe.presentRequestStatus(on: view, locale: locale) { [weak self] in + guard let self = self else { + return + } + [self.getPayChainAsset(), self.getReceiveChainAsset(), self.getFeeChainAsset()] + .compactMap { $0 } + .filter { $0.chainAssetId == chainAssetId } + .forEach(interactor.retryAssetBalanceSubscription) + } + case let .assetBalanceExistense(_, chainAsset): + wireframe.presentRequestStatus(on: view, locale: locale) { + interactor.retryAssetBalanceExistenseFetch(for: chainAsset) + } + case .accountInfo: + wireframe.presentRequestStatus(on: view, locale: locale) { + interactor.retryAccountInfoSubscription() + } + } + } + + func getBaseValidations( + for swapModel: SwapModel, + interactor: SwapBaseInteractorInputProtocol, + locale: Locale + ) -> [DataValidating] { + [ + dataValidatingFactory.hasInPlank( + fee: swapModel.feeModel?.totalFee.targetAmount, + locale: locale, + precision: swapModel.feeChainAsset.assetDisplayInfo.assetPrecision + ) { [weak self] in + self?.estimateFee() + }, + dataValidatingFactory.hasSufficientBalance( + params: swapModel, + swapMaxAction: { [weak self] in + self?.applySwapMax() + }, + locale: locale + ), + dataValidatingFactory.notViolatingMinBalancePaying( + fee: swapModel.feeChainAsset.isUtilityAsset ? swapModel.feeModel?.totalFee.targetAmount : 0, + total: swapModel.utilityAssetBalance?.freeInPlank, + minBalance: swapModel.feeChainAsset.isUtilityAsset ? swapModel.utilityAssetExistense?.minBalance : 0, + locale: locale + ), + dataValidatingFactory.canReceive(params: swapModel, locale: locale), + dataValidatingFactory.noDustRemains( + params: swapModel, + swapMaxAction: { [weak self] in + self?.applySwapMax() + }, + locale: locale + ), + dataValidatingFactory.passesRealtimeQuoteValidation( + params: swapModel, + remoteValidatingClosure: { args, completion in + interactor.requestValidatingQuote(for: args, completion: completion) + }, + onQuoteUpdate: { [weak self] quote in + self?.quoteResult = .success(quote) + self?.handleNewQuote(quote, for: swapModel.quoteArgs) + }, + locale: locale + ) + ] + } +} + +extension SwapBasePresenter: SwapBaseInteractorOutputProtocol { + func didReceive(quote: AssetConversion.Quote, for quoteArgs: AssetConversion.QuoteArgs) { + guard shouldHandleQuote(for: quoteArgs), self.quote != quote else { + return + } + + quoteResult = .success(quote) + + handleNewQuote(quote, for: quoteArgs) + } + + func didReceive( + fee: AssetConversion.FeeModel?, + transactionFeeId: TransactionFeeId, + feeChainAssetId: ChainAssetId? + ) { + guard shouldHandleFee(for: transactionFeeId, feeChainAssetId: feeChainAssetId), self.fee != fee else { + return + } + + self.fee = fee + + handleNewFee(fee, transactionFeeId: transactionFeeId, feeChainAssetId: feeChainAssetId) + } + + func didReceive(baseError: SwapBaseError) { + handleBaseError(baseError) + } + + func didReceive(price: PriceData?, priceId: AssetModel.PriceId) { + let optChainAssetId = [getPayChainAsset(), getReceiveChainAsset(), getFeeChainAsset()] + .compactMap { $0 } + .filter { $0.asset.priceId == priceId } + .first?.chainAssetId + + guard let chainAssetId = optChainAssetId, prices[chainAssetId] != price else { + return + } + + prices[chainAssetId] = price + + handleNewPrice(price, chainAssetId: chainAssetId) + } + + func didReceive(balance: AssetBalance?, for chainAsset: ChainAssetId) { + guard balances[chainAsset] != balance else { + return + } + + balances[chainAsset] = balance + + handleNewBalance(balance, for: chainAsset) + } + + func didReceiveAssetBalance(existense: AssetBalanceExistence, chainAssetId: ChainAssetId) { + guard assetBalanceExistences[chainAssetId] != existense else { + return + } + + assetBalanceExistences[chainAssetId] = existense + + handleNewBalanceExistense(existense, chainAssetId: chainAssetId) + } + + func didReceive(accountInfo: AccountInfo?, chainId: ChainModel.Id) { + guard self.accountInfo != accountInfo else { + return + } + + logger.debug("New account info: \(String(describing: accountInfo))") + + self.accountInfo = accountInfo + + handleNewAccountInfo(accountInfo, chainId: chainId) + } +} diff --git a/novawallet/Modules/Swaps/Base/SwapBaseProtocols.swift b/novawallet/Modules/Swaps/Base/SwapBaseProtocols.swift new file mode 100644 index 0000000000..744127e863 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/SwapBaseProtocols.swift @@ -0,0 +1,37 @@ +import BigInt + +protocol SwapBaseInteractorInputProtocol: AnyObject { + func setup() + func calculateQuote(for args: AssetConversion.QuoteArgs) + func calculateFee(args: AssetConversion.CallArgs) + func remakePriceSubscription(for chainAsset: ChainAsset) + func retryAssetBalanceSubscription(for chainAsset: ChainAsset) + func retryAssetBalanceExistenseFetch(for chainAsset: ChainAsset) + func retryAccountInfoSubscription() + func requestValidatingQuote( + for args: AssetConversion.QuoteArgs, + completion: @escaping (Result) -> Void + ) +} + +protocol SwapBaseInteractorOutputProtocol: AnyObject { + func didReceive(quote: AssetConversion.Quote, for quoteArgs: AssetConversion.QuoteArgs) + func didReceive(fee: AssetConversion.FeeModel?, transactionFeeId: TransactionFeeId, feeChainAssetId: ChainAssetId?) + func didReceive(baseError: SwapBaseError) + func didReceive(price: PriceData?, priceId: AssetModel.PriceId) + func didReceive(balance: AssetBalance?, for chainAsset: ChainAssetId) + func didReceiveAssetBalance(existense: AssetBalanceExistence, chainAssetId: ChainAssetId) + func didReceive(accountInfo: AccountInfo?, chainId: ChainModel.Id) +} + +protocol SwapBaseWireframeProtocol: AnyObject, SwapErrorPresentable, AlertPresentable, + CommonRetryable, ErrorPresentable {} + +enum SwapBaseError: Error { + case quote(Error, AssetConversion.QuoteArgs) + case fetchFeeFailed(Error, TransactionFeeId, FeeChainAssetId?) + case price(Error, AssetModel.PriceId) + case assetBalance(Error, ChainAssetId, AccountId) + case assetBalanceExistense(Error, ChainAsset) + case accountInfo(Error) +} diff --git a/novawallet/Modules/Swaps/Base/SwapPriceDifferenceViewModelFactoryProtocol.swift b/novawallet/Modules/Swaps/Base/SwapPriceDifferenceViewModelFactoryProtocol.swift new file mode 100644 index 0000000000..45bdfddfb3 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/SwapPriceDifferenceViewModelFactoryProtocol.swift @@ -0,0 +1,55 @@ +import Foundation + +protocol SwapPriceDifferenceViewModelFactoryProtocol { + var localizedPercentForamatter: NumberFormatter { get } + var priceDifferenceWarningRange: (start: Decimal, end: Decimal) { get } + + func priceDifferenceViewModel( + rateParams: RateParams, + priceIn: PriceData?, + priceOut: PriceData? + ) -> DifferenceViewModel? +} + +extension SwapPriceDifferenceViewModelFactoryProtocol { + func priceDifferenceViewModel( + rateParams params: RateParams, + priceIn: PriceData?, + priceOut: PriceData? + ) -> DifferenceViewModel? { + guard + let amountOutDecimal = Decimal.fromSubstrateAmount( + params.amountOut, + precision: params.assetDisplayInfoOut.assetPrecision + ), + let amountInDecimal = Decimal.fromSubstrateAmount( + params.amountIn, + precision: params.assetDisplayInfoIn.assetPrecision + ) else { + return nil + } + guard let priceIn = priceIn?.decimalRate, + let priceOut = priceOut?.decimalRate else { + return nil + } + + let amountPriceIn = amountInDecimal * priceIn + let amountPriceOut = amountOutDecimal * priceOut + + guard amountPriceIn != 0, amountPriceIn > amountPriceOut else { + return nil + } + + let diff = abs(amountPriceIn - amountPriceOut) / amountPriceIn + let diffString = localizedPercentForamatter.stringFromDecimal(diff)?.inParenthesis() ?? "" + + switch diff { + case _ where diff > priceDifferenceWarningRange.end: + return .init(details: diffString, attention: .high) + case priceDifferenceWarningRange.start ..< priceDifferenceWarningRange.end: + return .init(details: diffString, attention: .medium) + default: + return .init(details: diffString, attention: .low) + } + } +} diff --git a/novawallet/Modules/Swaps/Base/View/SwapInfoView.swift b/novawallet/Modules/Swaps/Base/View/SwapInfoView.swift new file mode 100644 index 0000000000..2bf82caa1a --- /dev/null +++ b/novawallet/Modules/Swaps/Base/View/SwapInfoView.swift @@ -0,0 +1,95 @@ +import UIKit +import SoraUI + +final class SwapInfoView: GenericTitleValueView, SkeletonableView { + var titleButton: RoundedButton { titleView } + var valueLabel: UILabel { valueView } + var skeletonView: SkrullableView? + + private var isLoading: Bool = false + + override func layoutSubviews() { + super.layoutSubviews() + + if isLoading { + updateLoadingState() + skeletonView?.restartSkrulling() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .clear + + configure() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configure() { + titleButton.applyIconStyle() + titleButton.imageWithTitleView?.iconImage = R.image.iconInfoFilled() + titleButton.imageWithTitleView?.titleColor = R.color.colorTextSecondary() + titleButton.imageWithTitleView?.titleFont = .regularFootnote + titleButton.imageWithTitleView?.spacingBetweenLabelAndIcon = 4 + titleButton.imageWithTitleView?.layoutType = .horizontalLabelFirst + titleButton.contentInsets = .init(top: 8, left: 0, bottom: 8, right: 0) + + valueLabel.textColor = R.color.colorTextPrimary() + valueLabel.font = .regularFootnote + } +} + +extension SwapInfoView { + func bind(loadableViewModel: LoadableViewModelState) { + switch loadableViewModel { + case let .cached(value), let .loaded(value): + stopLoadingIfNeeded() + isLoading = false + valueView.text = value + case .loading: + startLoadingIfNeeded() + isLoading = true + } + } +} + +extension SwapInfoView { + func createSkeletons(for spaceSize: CGSize) -> [Skeletonable] { + let size = CGSize(width: 68, height: 8) + let offset = CGPoint( + x: spaceSize.width - size.width, + y: spaceSize.height / 2.0 - size.height / 2.0 + ) + + let row = SingleSkeleton.createRow( + on: self, + containerView: self, + spaceSize: spaceSize, + offset: offset, + size: size + ) + + return [row] + } + + var skeletonSuperview: UIView { + self + } + + var hidingViews: [UIView] { + [valueView] + } + + func didStartSkeleton() { + isLoading = true + } + + func didStopSkeleton() { + isLoading = false + } +} diff --git a/novawallet/Modules/Swaps/Base/View/SwapInfoViewCell.swift b/novawallet/Modules/Swaps/Base/View/SwapInfoViewCell.swift new file mode 100644 index 0000000000..22e0cf7c10 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/View/SwapInfoViewCell.swift @@ -0,0 +1,36 @@ +import SoraUI + +final class SwapInfoViewCell: RowView, StackTableViewCellProtocol { + var titleButton: RoundedButton { rowContentView.titleView } + var valueLabel: UILabel { rowContentView.valueView } + + func bind(loadableViewModel: LoadableViewModelState) { + rowContentView.bind(loadableViewModel: loadableViewModel) + } +} + +extension SwapInfoViewCell { + func bind(attention: AttentionState) { + switch attention { + case .high: + valueLabel.textColor = R.color.colorTextNegative() + case .medium: + valueLabel.textColor = R.color.colorTextWarning() + case .low: + valueLabel.textColor = R.color.colorTextPrimary() + } + } + + func bind(differenceViewModel: LoadableViewModelState) { + switch differenceViewModel { + case .loading: + bind(loadableViewModel: .loading) + case let .cached(value): + bind(attention: value.attention) + bind(loadableViewModel: .cached(value: value.details)) + case let .loaded(value): + bind(attention: value.attention) + bind(loadableViewModel: .loaded(value: value.details)) + } + } +} diff --git a/novawallet/Modules/Swaps/Base/View/SwapNetworkFeeView.swift b/novawallet/Modules/Swaps/Base/View/SwapNetworkFeeView.swift new file mode 100644 index 0000000000..2212f759f1 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/View/SwapNetworkFeeView.swift @@ -0,0 +1,119 @@ +import Foundation +import UIKit +import SoraUI +import Kingfisher + +final class SwapNetworkFeeView: GenericTitleValueView>, + SkeletonableView { + var titleButton: RoundedButton { titleView } + var valueTopButton: RoundedButton { valueView.fView } + var valueBottomLabel: UILabel { valueView.sView } + var skeletonView: SkrullableView? + private lazy var iconPencil = R.image.iconPencilEdit()! + + private var isLoading: Bool = false + + override func layoutSubviews() { + super.layoutSubviews() + + if isLoading { + updateLoadingState() + skeletonView?.restartSkrulling() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .clear + + configure() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configure() { + titleButton.imageWithTitleView?.titleColor = R.color.colorTextSecondary() + titleButton.imageWithTitleView?.titleFont = .regularFootnote + titleButton.imageWithTitleView?.iconImage = R.image.iconInfoFilled() + titleButton.imageWithTitleView?.spacingBetweenLabelAndIcon = 4 + titleButton.imageWithTitleView?.layoutType = .horizontalLabelFirst + titleButton.applyIconStyle() + titleButton.contentInsets = .init(top: 8, left: 0, bottom: 8, right: 0) + + valueTopButton.applyIconStyle() + valueTopButton.imageWithTitleView?.iconImage = iconPencil + valueTopButton.imageWithTitleView?.titleColor = R.color.colorTextPrimary() + valueTopButton.imageWithTitleView?.titleFont = .regularFootnote + valueTopButton.imageWithTitleView?.spacingBetweenLabelAndIcon = 6 + valueTopButton.contentInsets = .init(top: 8, left: 0, bottom: 8, right: 0) + + valueBottomLabel.textColor = R.color.colorTextSecondary() + valueBottomLabel.font = .caption1 + valueBottomLabel.textAlignment = .right + + valueView.makeVertical() + valueTopButton.contentInsets = .zero + } +} + +extension SwapNetworkFeeView { + func bind(viewModel: SwapFeeViewModel) { + valueTopButton.imageWithTitleView?.iconImage = viewModel.isEditable ? iconPencil : nil + valueTopButton.isUserInteractionEnabled = viewModel.isEditable + valueTopButton.imageWithTitleView?.title = viewModel.balanceViewModel.amount + valueBottomLabel.text = viewModel.balanceViewModel.price + valueTopButton.invalidateLayout() + } + + func bind(loadableViewModel: LoadableViewModelState) { + switch loadableViewModel { + case let .cached(value), let .loaded(value): + isLoading = false + stopLoadingIfNeeded() + bind(viewModel: value) + case .loading: + isLoading = true + startLoadingIfNeeded() + } + } +} + +extension SwapNetworkFeeView { + func createSkeletons(for spaceSize: CGSize) -> [Skeletonable] { + let size = CGSize(width: 68, height: 8) + let offset = CGPoint( + x: spaceSize.width - size.width, + y: spaceSize.height / 2.0 - size.height / 2.0 + ) + + let row = SingleSkeleton.createRow( + on: self, + containerView: self, + spaceSize: spaceSize, + offset: offset, + size: size + ) + + return [row] + } + + var skeletonSuperview: UIView { + self + } + + var hidingViews: [UIView] { + [valueView] + } + + func didStartSkeleton() { + isLoading = true + } + + func didStopSkeleton() { + isLoading = false + } +} diff --git a/novawallet/Modules/Swaps/Base/View/SwapNetworkFeeViewCell.swift b/novawallet/Modules/Swaps/Base/View/SwapNetworkFeeViewCell.swift new file mode 100644 index 0000000000..6b7ef4cc54 --- /dev/null +++ b/novawallet/Modules/Swaps/Base/View/SwapNetworkFeeViewCell.swift @@ -0,0 +1,20 @@ +import SoraUI + +final class SwapNetworkFeeViewCell: RowView, StackTableViewCellProtocol { + var titleButton: RoundedButton { rowContentView.titleView } + var valueTopButton: RoundedButton { rowContentView.valueView.fView } + var valueBottomLabel: UILabel { rowContentView.valueView.sView } + + func bind(loadableViewModel: LoadableViewModelState) { + rowContentView.bind(loadableViewModel: loadableViewModel) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let pointInContentViewSpace = convert(point, to: rowContentView) + if valueTopButton.isUserInteractionEnabled, rowContentView.valueView.frame.contains(pointInContentViewSpace) { + return valueTopButton + } else { + return super.hitTest(point, with: event) + } + } +} diff --git a/novawallet/Modules/Swaps/Confirm/Model/SwapConfirmInitState.swift b/novawallet/Modules/Swaps/Confirm/Model/SwapConfirmInitState.swift new file mode 100644 index 0000000000..ec4d9d2251 --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/Model/SwapConfirmInitState.swift @@ -0,0 +1,8 @@ +struct SwapConfirmInitState { + let chainAssetIn: ChainAsset + let chainAssetOut: ChainAsset + let feeChainAsset: ChainAsset + let slippage: BigRational + let quote: AssetConversion.Quote + let quoteArgs: AssetConversion.QuoteArgs +} diff --git a/novawallet/Modules/Swaps/Confirm/Model/SwapConfirmViewModelFactory.swift b/novawallet/Modules/Swaps/Confirm/Model/SwapConfirmViewModelFactory.swift new file mode 100644 index 0000000000..5a4b925c97 --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/Model/SwapConfirmViewModelFactory.swift @@ -0,0 +1,99 @@ +import Foundation +import SoraFoundation +import BigInt + +protocol SwapConfirmViewModelFactoryProtocol: SwapBaseViewModelFactoryProtocol { + func assetViewModel( + chainAsset: ChainAsset, + amount: BigUInt, + priceData: PriceData?, + locale: Locale + ) -> SwapAssetAmountViewModel + + func slippageViewModel(slippage: BigRational, locale: Locale) -> String + + func feeViewModel( + fee: BigUInt, + chainAsset: ChainAsset, + priceData: PriceData?, + locale: Locale + ) -> SwapFeeViewModel + + func walletViewModel(walletAddress: WalletDisplayAddress) -> WalletAccountViewModel? +} + +final class SwapConfirmViewModelFactory: SwapBaseViewModelFactory { + let walletViewModelFactory = WalletAccountViewModelFactory() + let networkViewModelFactory: NetworkViewModelFactoryProtocol + + init( + balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol, + networkViewModelFactory: NetworkViewModelFactoryProtocol, + percentForamatter: LocalizableResource, + priceDifferenceConfig: SwapPriceDifferenceConfig + ) { + self.networkViewModelFactory = networkViewModelFactory + + super.init( + balanceViewModelFactoryFacade: balanceViewModelFactoryFacade, + percentForamatter: percentForamatter, + priceDifferenceConfig: priceDifferenceConfig + ) + } +} + +extension SwapConfirmViewModelFactory: SwapConfirmViewModelFactoryProtocol { + func assetViewModel( + chainAsset: ChainAsset, + amount: BigUInt, + priceData: PriceData?, + locale: Locale + ) -> SwapAssetAmountViewModel { + let networkViewModel = networkViewModelFactory.createViewModel(from: chainAsset.chain) + let assetIcon: ImageViewModelProtocol = chainAsset.asset.icon.map { RemoteImageViewModel(url: $0) } ?? + StaticImageViewModel(image: R.image.iconDefaultToken()!) + let amountDecimal = Decimal.fromSubstrateAmount( + amount, + precision: chainAsset.assetDisplayInfo.assetPrecision + ) ?? 0 + let balanceViewModel = balanceViewModelFactoryFacade.balanceFromPrice( + targetAssetInfo: chainAsset.assetDisplayInfo, + amount: amountDecimal, + priceData: priceData + ).value(for: locale) + + return .init( + imageViewModel: assetIcon, + hub: networkViewModel, + amount: balanceViewModel.amount, + price: balanceViewModel.price.map { $0.approximately() } + ) + } + + func slippageViewModel(slippage: BigRational, locale: Locale) -> String { + slippage.decimalValue.map { percentForamatter.value(for: locale).stringFromDecimal($0) ?? "" } ?? "" + } + + func feeViewModel( + fee: BigUInt, + chainAsset: ChainAsset, + priceData: PriceData?, + locale: Locale + ) -> SwapFeeViewModel { + let amountDecimal = Decimal.fromSubstrateAmount( + fee, + precision: chainAsset.assetDisplayInfo.assetPrecision + ) ?? 0 + let balanceViewModel = balanceViewModelFactoryFacade.balanceFromPrice( + targetAssetInfo: chainAsset.assetDisplayInfo, + amount: amountDecimal, + priceData: priceData + ).value(for: locale) + + return .init(isEditable: false, balanceViewModel: balanceViewModel) + } + + func walletViewModel(walletAddress: WalletDisplayAddress) -> WalletAccountViewModel? { + try? walletViewModelFactory.createViewModel(from: walletAddress) + } +} diff --git a/novawallet/Modules/Swaps/Confirm/Model/SwapConfirmViewModels.swift b/novawallet/Modules/Swaps/Confirm/Model/SwapConfirmViewModels.swift new file mode 100644 index 0000000000..1ee05a4848 --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/Model/SwapConfirmViewModels.swift @@ -0,0 +1,17 @@ +struct SwapAssetAmountViewModel { + let imageViewModel: ImageViewModelProtocol? + let hub: NetworkViewModel + let amount: String + let price: String? +} + +struct DifferenceViewModel { + let details: String + let attention: AttentionState +} + +enum AttentionState { + case high + case medium + case low +} diff --git a/novawallet/Modules/Swaps/Confirm/SwapConfirmInteractor.swift b/novawallet/Modules/Swaps/Confirm/SwapConfirmInteractor.swift new file mode 100644 index 0000000000..47c1f1c98e --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/SwapConfirmInteractor.swift @@ -0,0 +1,190 @@ +import UIKit +import RobinHood +import IrohaCrypto +import BigInt + +final class SwapConfirmInteractor: SwapBaseInteractor { + var presenter: SwapConfirmInteractorOutputProtocol? { + basePresenter as? SwapConfirmInteractorOutputProtocol + } + + let persistExtrinsicService: PersistentExtrinsicServiceProtocol + let eventCenter: EventCenterProtocol + let initState: SwapConfirmInitState + let runtimeService: RuntimeProviderProtocol + let extrinsicServiceFactory: ExtrinsicServiceFactoryProtocol + let assetConversionExtrinsicService: AssetConversionExtrinsicServiceProtocol + let signer: SigningWrapperProtocol + + init( + initState: SwapConfirmInitState, + assetConversionFeeService: AssetConversionFeeServiceProtocol, + assetConversionAggregator: AssetConversionAggregationFactoryProtocol, + assetConversionExtrinsicService: AssetConversionExtrinsicServiceProtocol, + chainRegistry: ChainRegistryProtocol, + assetStorageFactory: AssetStorageInfoOperationFactoryProtocol, + runtimeService: RuntimeProviderProtocol, + extrinsicServiceFactory: ExtrinsicServiceFactoryProtocol, + priceLocalSubscriptionFactory: PriceProviderFactoryProtocol, + walletLocalSubscriptionFactory: WalletLocalSubscriptionFactoryProtocol, + generalLocalSubscriptionFactory: GeneralStorageSubscriptionFactoryProtocol, + persistExtrinsicService: PersistentExtrinsicServiceProtocol, + eventCenter: EventCenterProtocol, + currencyManager: CurrencyManagerProtocol, + selectedWallet: MetaAccountModel, + operationQueue: OperationQueue, + signer: SigningWrapperProtocol + ) { + self.initState = initState + self.signer = signer + self.runtimeService = runtimeService + self.extrinsicServiceFactory = extrinsicServiceFactory + self.assetConversionExtrinsicService = assetConversionExtrinsicService + self.persistExtrinsicService = persistExtrinsicService + self.eventCenter = eventCenter + + super.init( + assetConversionAggregator: assetConversionAggregator, + assetConversionFeeService: assetConversionFeeService, + chainRegistry: chainRegistry, + assetStorageFactory: assetStorageFactory, + priceLocalSubscriptionFactory: priceLocalSubscriptionFactory, + walletLocalSubscriptionFactory: walletLocalSubscriptionFactory, + generalSubscriptionFactory: generalLocalSubscriptionFactory, + currencyManager: currencyManager, + selectedWallet: selectedWallet, + operationQueue: operationQueue + ) + } + + private func persistSwapAndComplete(txHash: String, args: AssetConversion.CallArgs, lastFee: BigUInt?) { + do { + let chainIn = initState.chainAssetIn.chain + + guard let sender = selectedWallet.fetch(for: chainIn.accountRequest())?.toAddress() else { + throw ChainAccountFetchingError.accountNotExists + } + + let receiver = try args.receiver.toAddress(using: initState.chainAssetOut.chain.chainFormat) + + let details = PersistSwapDetails( + txHash: try Data(hexString: txHash), + sender: sender, + receiver: receiver, + assetIdIn: args.assetIn, + amountIn: args.amountIn, + assetIdOut: args.assetOut, + amountOut: args.amountOut, + fee: lastFee, + feeAssetId: initState.feeChainAsset.asset.assetId, + callPath: AssetConversionPallet.callPath(for: args.direction) + ) + + persistExtrinsicService.saveSwap( + source: .substrate, + chainAssetId: details.assetIdIn, + details: details, + runningIn: .main + ) { [weak self] _ in + self?.eventCenter.notify(with: WalletTransactionListUpdated()) + self?.presenter?.didReceiveConfirmation(hash: txHash) + } + } catch { + // complete successfully as we don't want a user to think tx is failed + presenter?.didReceiveConfirmation(hash: txHash) + } + } + + override func setup() { + super.setup() + + set(payChainAsset: initState.chainAssetIn) + set(receiveChainAsset: initState.chainAssetOut) + set(feeChainAsset: initState.feeChainAsset) + } + + func submitExtrinsic(args: AssetConversion.CallArgs, lastFee: BigUInt?) { + let runtimeCoderFactoryOperation = runtimeService.fetchCoderFactoryOperation() + + runtimeCoderFactoryOperation.completionBlock = { [weak self] in + DispatchQueue.main.async { + guard let self = self else { + return + } + do { + let runtimeCoderFactory = try runtimeCoderFactoryOperation.extractNoCancellableResultData() + let builder = self.assetConversionExtrinsicService.fetchExtrinsicBuilderClosure( + for: args, + codingFactory: runtimeCoderFactory + ) + try self.submitClosure( + builder: builder, + runtimeCoderFactory: runtimeCoderFactory, + args: args, + lastFee: lastFee + ) + } catch { + self.presenter?.didReceive(error: .submit(error)) + } + } + } + + operationQueue.addOperation(runtimeCoderFactoryOperation) + } + + private func submitClosure( + builder: @escaping ExtrinsicBuilderClosure, + runtimeCoderFactory: RuntimeCoderFactoryProtocol, + args: AssetConversion.CallArgs, + lastFee: BigUInt? + ) throws { + let extrinsicService: ExtrinsicServiceProtocol + + guard let account = chainAccountResponse(for: initState.feeChainAsset) else { + throw ChainAccountFetchingError.accountNotExists + } + + if initState.feeChainAsset.isUtilityAsset { + extrinsicService = extrinsicServiceFactory.createService( + account: account, + chain: initState.feeChainAsset.chain + ) + } else { + guard let assetId = AssetHubTokensConverter.convertToMultilocation( + chainAsset: initState.feeChainAsset, + codingFactory: runtimeCoderFactory + ) else { + throw SwapConfirmError.submit(CommonError.dataCorruption) + } + + extrinsicService = extrinsicServiceFactory.createService( + account: account, + chain: initState.feeChainAsset.chain, + feeAssetConversionId: assetId + ) + } + + extrinsicService.submit( + builder, + signer: signer, + runningIn: .main + ) { [weak self] result in + switch result { + case let .success(hash): + self?.persistSwapAndComplete( + txHash: hash, + args: args, + lastFee: lastFee + ) + case let .failure(error): + self?.presenter?.didReceive(error: .submit(error)) + } + } + } +} + +extension SwapConfirmInteractor: SwapConfirmInteractorInputProtocol { + func submit(args: AssetConversion.CallArgs, lastFee: BigUInt?) { + submitExtrinsic(args: args, lastFee: lastFee) + } +} diff --git a/novawallet/Modules/Swaps/Confirm/SwapConfirmPresenter.swift b/novawallet/Modules/Swaps/Confirm/SwapConfirmPresenter.swift new file mode 100644 index 0000000000..e359e0115c --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/SwapConfirmPresenter.swift @@ -0,0 +1,447 @@ +import Foundation +import BigInt +import SoraFoundation + +final class SwapConfirmPresenter: SwapBasePresenter { + weak var view: SwapConfirmViewProtocol? + let wireframe: SwapConfirmWireframeProtocol + let interactor: SwapConfirmInteractorInputProtocol + let initState: SwapConfirmInitState + let slippageBounds: SlippageBounds + + var accountId: AccountId? { + selectedWallet.fetch(for: initState.chainAssetOut.chain.accountRequest())?.accountId + } + + private var viewModelFactory: SwapConfirmViewModelFactoryProtocol + + private var quoteArgs: AssetConversion.QuoteArgs + + init( + interactor: SwapConfirmInteractorInputProtocol, + wireframe: SwapConfirmWireframeProtocol, + initState: SwapConfirmInitState, + selectedWallet: MetaAccountModel, + viewModelFactory: SwapConfirmViewModelFactoryProtocol, + slippageBounds: SlippageBounds, + dataValidatingFactory: SwapDataValidatorFactoryProtocol, + localizationManager: LocalizationManagerProtocol, + logger: LoggerProtocol + ) { + self.interactor = interactor + self.wireframe = wireframe + self.viewModelFactory = viewModelFactory + self.slippageBounds = slippageBounds + self.initState = initState + quoteArgs = initState.quoteArgs + + super.init( + selectedWallet: selectedWallet, + dataValidatingFactory: dataValidatingFactory, + logger: logger + ) + + quoteResult = .success(initState.quote) + self.localizationManager = localizationManager + } + + override func getSpendingInputAmount() -> Decimal? { + quote?.amountIn.decimal(precision: initState.chainAssetIn.asset.precision) + } + + override func getQuoteArgs() -> AssetConversion.QuoteArgs? { + quoteArgs + } + + override func getSlippage() -> BigRational { + initState.slippage + } + + override func getPayChainAsset() -> ChainAsset? { + initState.chainAssetIn + } + + override func getReceiveChainAsset() -> ChainAsset? { + initState.chainAssetOut + } + + override func getFeeChainAsset() -> ChainAsset? { + initState.feeChainAsset + } + + override func shouldHandleQuote(for quoteArgs: AssetConversion.QuoteArgs?) -> Bool { + self.quoteArgs == quoteArgs + } + + override func shouldHandleFee(for _: TransactionFeeId, feeChainAssetId _: ChainAssetId?) -> Bool { + true + } + + override func estimateFee() { + guard let quote = quote, + let accountId = selectedWallet.fetch( + for: initState.chainAssetOut.chain.accountRequest() + )?.accountId else { + return + } + + fee = nil + provideFeeViewModel() + + interactor.calculateFee( + args: .init( + assetIn: quote.assetIn, + amountIn: quote.amountIn, + assetOut: quote.assetOut, + amountOut: quote.amountOut, + receiver: accountId, + direction: quoteArgs.direction, + slippage: initState.slippage + ) + ) + } + + override func applySwapMax() { + guard + let maxAmount = getMaxModel()?.calculate(), + maxAmount > 0, + let maxAmountInPlank = maxAmount.toSubstrateAmount( + precision: initState.chainAssetIn.assetDisplayInfo.assetPrecision + ) else { + return + } + + quoteArgs = AssetConversion.QuoteArgs( + assetIn: initState.quoteArgs.assetIn, + assetOut: initState.quoteArgs.assetOut, + amount: maxAmountInPlank, + direction: .sell + ) + + view?.didReceiveStartLoading() + + interactor.calculateQuote(for: quoteArgs) + } + + override func handleBaseError(_ error: SwapBaseError) { + handleBaseError( + error, + view: view, + interactor: interactor, + wireframe: wireframe, + locale: selectedLocale + ) + + if case .quote = error { + view?.didReceiveStopLoading() + } + } + + override func handleNewQuote(_ quote: AssetConversion.Quote, for _: AssetConversion.QuoteArgs) { + quoteResult = .success(quote) + + updateViews() + estimateFee() + } + + override func handleNewFee( + _: AssetConversion.FeeModel?, + transactionFeeId _: TransactionFeeId, + feeChainAssetId _: ChainAssetId? + ) { + provideFeeViewModel() + provideNotificationViewModel() + } + + override func handleNewPrice(_: PriceData?, chainAssetId: ChainAssetId) { + if initState.chainAssetIn.chainAssetId == chainAssetId { + provideAssetInViewModel() + providePriceDifferenceViewModel() + } + + if initState.chainAssetOut.chainAssetId == chainAssetId { + provideAssetOutViewModel() + providePriceDifferenceViewModel() + } + + if initState.feeChainAsset.chainAssetId == chainAssetId { + provideFeeViewModel() + } + } +} + +extension SwapConfirmPresenter { + private func provideAssetInViewModel() { + guard let quote = quote else { + return + } + + let viewModel = viewModelFactory.assetViewModel( + chainAsset: initState.chainAssetIn, + amount: quote.amountIn, + priceData: payAssetPriceData, + locale: selectedLocale + ) + + view?.didReceiveAssetIn(viewModel: viewModel) + } + + private func provideAssetOutViewModel() { + guard let quote = quote else { + return + } + let viewModel = viewModelFactory.assetViewModel( + chainAsset: initState.chainAssetOut, + amount: quote.amountOut, + priceData: receiveAssetPriceData, + locale: selectedLocale + ) + view?.didReceiveAssetOut(viewModel: viewModel) + } + + private func provideRateViewModel() { + guard let quote = quote else { + view?.didReceiveRate(viewModel: .loading) + return + } + + let params = RateParams( + assetDisplayInfoIn: initState.chainAssetIn.assetDisplayInfo, + assetDisplayInfoOut: initState.chainAssetOut.assetDisplayInfo, + amountIn: quote.amountIn, + amountOut: quote.amountOut + ) + let viewModel = viewModelFactory.rateViewModel(from: params, locale: selectedLocale) + + view?.didReceiveRate(viewModel: .loaded(value: viewModel)) + } + + private func providePriceDifferenceViewModel() { + guard let quote = quote else { + view?.didReceivePriceDifference(viewModel: .loading) + return + } + + let params = RateParams( + assetDisplayInfoIn: initState.chainAssetIn.assetDisplayInfo, + assetDisplayInfoOut: initState.chainAssetOut.assetDisplayInfo, + amountIn: quote.amountIn, + amountOut: quote.amountOut + ) + + if let viewModel = viewModelFactory.priceDifferenceViewModel( + rateParams: params, + priceIn: payAssetPriceData, + priceOut: receiveAssetPriceData, + locale: selectedLocale + ) { + view?.didReceivePriceDifference(viewModel: .loaded(value: viewModel)) + } else { + view?.didReceivePriceDifference(viewModel: nil) + } + } + + private func provideSlippageViewModel() { + let viewModel = viewModelFactory.slippageViewModel(slippage: initState.slippage, locale: selectedLocale) + view?.didReceiveSlippage(viewModel: viewModel) + let warning = slippageBounds.warning(for: initState.slippage.decimalValue, locale: selectedLocale) + view?.didReceiveWarning(viewModel: warning) + } + + private func provideNotificationViewModel() { + guard + let networkFeeAddition = fee?.networkNativeFeeAddition, + !initState.feeChainAsset.isUtilityAsset, + let utilityChainAsset = initState.feeChainAsset.chain.utilityChainAsset() else { + view?.didReceiveNotification(viewModel: nil) + return + } + + let message = viewModelFactory.minimalBalanceSwapForFeeMessage( + for: networkFeeAddition, + feeChainAsset: initState.feeChainAsset, + utilityChainAsset: utilityChainAsset, + utilityPriceData: prices[utilityChainAsset.chainAssetId], + locale: selectedLocale + ) + + view?.didReceiveNotification(viewModel: message) + } + + private func provideFeeViewModel() { + guard let fee = fee else { + view?.didReceiveNetworkFee(viewModel: .loading) + return + } + + let viewModel = viewModelFactory.feeViewModel( + fee: fee.networkFee.targetAmount, + chainAsset: initState.feeChainAsset, + priceData: feeAssetPriceData, + locale: selectedLocale + ) + + view?.didReceiveNetworkFee(viewModel: .loaded(value: viewModel)) + } + + private func provideWalletViewModel() { + guard let chainAccountResponse = selectedWallet.fetchMetaChainAccount( + for: initState.chainAssetOut.chain.accountRequest() + ) else { + return + } + + guard let walletAddress = WalletDisplayAddress(response: chainAccountResponse) else { + view?.didReceiveWallet(viewModel: nil) + return + } + let viewModel = viewModelFactory.walletViewModel(walletAddress: walletAddress) + + view?.didReceiveWallet(viewModel: viewModel) + } + + private func updateViews() { + provideAssetInViewModel() + provideAssetOutViewModel() + provideRateViewModel() + providePriceDifferenceViewModel() + provideSlippageViewModel() + provideNotificationViewModel() + provideFeeViewModel() + provideWalletViewModel() + } + + private func submit() { + guard let quote = quote, let accountId = accountId else { + return + } + + let args = AssetConversion.CallArgs( + assetIn: quote.assetIn, + amountIn: quote.amountIn, + assetOut: quote.assetOut, + amountOut: quote.amountOut, + receiver: accountId, + direction: initState.quoteArgs.direction, + slippage: initState.slippage + ) + + view?.didReceiveStartLoading() + + interactor.submit(args: args, lastFee: fee?.networkFee.targetAmount) + } +} + +extension SwapConfirmPresenter: SwapConfirmPresenterProtocol { + func setup() { + interactor.setup() + estimateFee() + updateViews() + } + + func showRateInfo() { + wireframe.showRateInfo(from: view) + } + + func showPriceDifferenceInfo() { + let title = LocalizableResource { + R.string.localizable.swapsSetupPriceDifference( + preferredLanguages: $0.rLanguages + ) + } + let details = LocalizableResource { + R.string.localizable.swapsSetupPriceDifferenceDescription( + preferredLanguages: $0.rLanguages + ) + } + wireframe.showInfo( + from: view, + title: title, + details: details + ) + } + + func showSlippageInfo() { + wireframe.showSlippageInfo(from: view) + } + + func showNetworkFeeInfo() { + wireframe.showFeeInfo(from: view) + } + + func showAddressOptions() { + guard let view = view else { + return + } + + guard let address = try? accountId?.toAddress(using: initState.chainAssetOut.chain.chainFormat) else { + return + } + + wireframe.presentAccountOptions( + from: view, + address: address, + chain: initState.chainAssetIn.chain, + locale: selectedLocale + ) + } + + func confirm() { + guard let swapModel = getSwapModel() else { + return + } + + let validators = getBaseValidations( + for: swapModel, + interactor: interactor, + locale: selectedLocale + ) + + DataValidationRunner(validators: validators).runValidation( + notifyingOnSuccess: { [weak self] in + self?.submit() + }, + notifyingOnStop: { [weak self] _ in + self?.view?.didReceiveStopLoading() + }, + notifyingOnResume: { [weak self] _ in + self?.view?.didReceiveStartLoading() + } + ) + } +} + +extension SwapConfirmPresenter: SwapConfirmInteractorOutputProtocol { + func didReceive(error: SwapConfirmError) { + view?.didReceiveStopLoading() + switch error { + case let .submit(error): + if error.isWatchOnlySigning { + wireframe.presentDismissingNoSigningView(from: view) + } else { + _ = wireframe.present(error: error, from: view, locale: selectedLocale) + } + } + } + + func didReceiveConfirmation(hash _: String) { + view?.didReceiveStopLoading() + + guard let payChainAsset = getPayChainAsset() else { + return + } + wireframe.complete( + on: view, + payChainAsset: payChainAsset, + locale: selectedLocale + ) + } +} + +extension SwapConfirmPresenter: Localizable { + func applyLocalization() { + if view?.isSetup == true { + updateViews() + } + } +} diff --git a/novawallet/Modules/Swaps/Confirm/SwapConfirmProtocols.swift b/novawallet/Modules/Swaps/Confirm/SwapConfirmProtocols.swift new file mode 100644 index 0000000000..566a38e840 --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/SwapConfirmProtocols.swift @@ -0,0 +1,48 @@ +import Foundation +import BigInt + +protocol SwapConfirmViewProtocol: ControllerBackedProtocol { + func didReceiveAssetIn(viewModel: SwapAssetAmountViewModel) + func didReceiveAssetOut(viewModel: SwapAssetAmountViewModel) + func didReceiveRate(viewModel: LoadableViewModelState) + func didReceivePriceDifference(viewModel: LoadableViewModelState?) + func didReceiveSlippage(viewModel: String) + func didReceiveNetworkFee(viewModel: LoadableViewModelState) + func didReceiveWallet(viewModel: WalletAccountViewModel?) + func didReceiveWarning(viewModel: String?) + func didReceiveNotification(viewModel: String?) + func didReceiveStartLoading() + func didReceiveStopLoading() +} + +protocol SwapConfirmPresenterProtocol: AnyObject { + func setup() + func showRateInfo() + func showPriceDifferenceInfo() + func showSlippageInfo() + func showNetworkFeeInfo() + func showAddressOptions() + func confirm() +} + +protocol SwapConfirmInteractorInputProtocol: SwapBaseInteractorInputProtocol { + func submit(args: AssetConversion.CallArgs, lastFee: BigUInt?) +} + +protocol SwapConfirmInteractorOutputProtocol: SwapBaseInteractorOutputProtocol { + func didReceiveConfirmation(hash: String) + func didReceive(error: SwapConfirmError) +} + +protocol SwapConfirmWireframeProtocol: SwapBaseWireframeProtocol, AddressOptionsPresentable, + ShortTextInfoPresentable, ModalAlertPresenting, MessageSheetPresentable { + func complete( + on view: ControllerBackedProtocol?, + payChainAsset: ChainAsset, + locale: Locale + ) +} + +enum SwapConfirmError: Error { + case submit(Error) +} diff --git a/novawallet/Modules/Swaps/Confirm/SwapConfirmViewController.swift b/novawallet/Modules/Swaps/Confirm/SwapConfirmViewController.swift new file mode 100644 index 0000000000..cd6ceb36ce --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/SwapConfirmViewController.swift @@ -0,0 +1,147 @@ +import UIKit +import SoraFoundation + +final class SwapConfirmViewController: UIViewController, ViewHolder { + typealias RootViewType = SwapConfirmViewLayout + + let presenter: SwapConfirmPresenterProtocol + + init( + presenter: SwapConfirmPresenterProtocol, + localizationManager: LocalizationManagerProtocol + ) { + self.presenter = presenter + super.init(nibName: nil, bundle: nil) + + self.localizationManager = localizationManager + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = SwapConfirmViewLayout() + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupLocalization() + setupHandlers() + presenter.setup() + } + + private func setupLocalization() { + rootView.setup(locale: selectedLocale) + title = R.string.localizable.commonSwapTitle( + preferredLanguages: selectedLocale.rLanguages + ) + } + + private func setupHandlers() { + rootView.rateCell.addTarget(self, action: #selector(rateAction), for: .touchUpInside) + rootView.priceDifferenceCell.addTarget(self, action: #selector(priceDifferenceAction), for: .touchUpInside) + rootView.slippageCell.addTarget(self, action: #selector(slippageAction), for: .touchUpInside) + rootView.networkFeeCell.addTarget(self, action: #selector(networkFeeAction), for: .touchUpInside) + rootView.accountCell.addTarget(self, action: #selector(addressAction), for: .touchUpInside) + rootView.loadableActionView.actionButton.addTarget(self, action: #selector(confirmAction), for: .touchUpInside) + } + + @objc private func rateAction() { + presenter.showRateInfo() + } + + @objc private func priceDifferenceAction() { + presenter.showPriceDifferenceInfo() + } + + @objc private func slippageAction() { + presenter.showSlippageInfo() + } + + @objc private func networkFeeAction() { + presenter.showNetworkFeeInfo() + } + + @objc private func addressAction() { + presenter.showAddressOptions() + } + + @objc private func confirmAction() { + presenter.confirm() + } +} + +extension SwapConfirmViewController: SwapConfirmViewProtocol { + func didReceiveAssetIn(viewModel: SwapAssetAmountViewModel) { + rootView.pairsView.leftAssetView.bind(viewModel: viewModel) + } + + func didReceiveAssetOut(viewModel: SwapAssetAmountViewModel) { + rootView.pairsView.rigthAssetView.bind(viewModel: viewModel) + } + + func didReceiveRate(viewModel: LoadableViewModelState) { + rootView.rateCell.bind(loadableViewModel: viewModel) + } + + func didReceivePriceDifference(viewModel: LoadableViewModelState?) { + if let viewModel = viewModel { + rootView.priceDifferenceCell.isHidden = false + rootView.priceDifferenceCell.bind(differenceViewModel: viewModel) + } else { + rootView.priceDifferenceCell.isHidden = true + } + } + + func didReceiveSlippage(viewModel: String) { + rootView.slippageCell.bind(loadableViewModel: .loaded(value: viewModel)) + } + + func didReceiveNetworkFee(viewModel: LoadableViewModelState) { + rootView.networkFeeCell.bind(loadableViewModel: viewModel) + } + + func didReceiveWallet(viewModel: WalletAccountViewModel?) { + guard let viewModel = viewModel else { + rootView.walletTableView.isHidden = true + return + } + rootView.walletTableView.isHidden = false + rootView.walletCell.bind(viewModel: .init( + details: viewModel.walletName ?? "", + imageViewModel: viewModel.walletIcon + )) + + rootView.accountCell.bind(viewModel: .init( + details: viewModel.address, + imageViewModel: viewModel.addressIcon + )) + } + + func didReceiveWarning(viewModel: String?) { + rootView.set(warning: viewModel) + } + + func didReceiveNotification(viewModel: String?) { + rootView.set(notification: viewModel) + } + + func didReceiveStartLoading() { + rootView.loadableActionView.startLoading() + } + + func didReceiveStopLoading() { + rootView.loadableActionView.stopLoading() + } +} + +extension SwapConfirmViewController: Localizable { + func applyLocalization() { + if isViewLoaded { + setupLocalization() + } + } +} diff --git a/novawallet/Modules/Swaps/Confirm/SwapConfirmViewFactory.swift b/novawallet/Modules/Swaps/Confirm/SwapConfirmViewFactory.swift new file mode 100644 index 0000000000..af8af10aa8 --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/SwapConfirmViewFactory.swift @@ -0,0 +1,139 @@ +import Foundation +import SoraFoundation +import RobinHood + +struct SwapConfirmViewFactory { + static func createView( + initState: SwapConfirmInitState, + generalSubscriptonFactory: GeneralStorageSubscriptionFactoryProtocol, + completionClosure: SwapCompletionClosure? + ) -> SwapConfirmViewProtocol? { + guard let currencyManager = CurrencyManager.shared, let wallet = SelectedWalletSettings.shared.value else { + return nil + } + + guard let interactor = createInteractor( + wallet: wallet, + initState: initState, + generalSubscriptonFactory: generalSubscriptonFactory + ) else { + return nil + } + + let wireframe = SwapConfirmWireframe(completionClosure: completionClosure) + + let balanceViewModelFactoryFacade = BalanceViewModelFactoryFacade( + priceAssetInfoFactory: PriceAssetInfoFactory(currencyManager: currencyManager) + ) + + let viewModelFactory = SwapConfirmViewModelFactory( + balanceViewModelFactoryFacade: balanceViewModelFactoryFacade, + networkViewModelFactory: NetworkViewModelFactory(), + percentForamatter: NumberFormatter.percentSingle.localizableResource(), + priceDifferenceConfig: .defaultConfig + ) + + let dataValidatingFactory = SwapDataValidatorFactory( + presentable: wireframe, + balanceViewModelFactoryFacade: balanceViewModelFactoryFacade + ) + + let presenter = SwapConfirmPresenter( + interactor: interactor, + wireframe: wireframe, + initState: initState, + selectedWallet: wallet, + viewModelFactory: viewModelFactory, + slippageBounds: .init(config: SlippageConfig.defaultConfig), + dataValidatingFactory: dataValidatingFactory, + localizationManager: LocalizationManager.shared, + logger: Logger.shared + ) + + let view = SwapConfirmViewController( + presenter: presenter, + localizationManager: LocalizationManager.shared + ) + + presenter.view = view + dataValidatingFactory.view = view + interactor.basePresenter = presenter + + return view + } + + private static func createInteractor( + wallet: MetaAccountModel, + initState: SwapConfirmInitState, + generalSubscriptonFactory: GeneralStorageSubscriptionFactoryProtocol + ) -> SwapConfirmInteractor? { + let chainRegistry = ChainRegistryFacade.sharedRegistry + let accountRequest = initState.chainAssetIn.chain.accountRequest() + + let chain = initState.chainAssetIn.chain + + guard let connection = chainRegistry.getConnection(for: chain.chainId), + let runtimeService = chainRegistry.getRuntimeProvider(for: chain.chainId), + let currencyManager = CurrencyManager.shared, + let selectedAccount = wallet.fetchMetaChainAccount(for: accountRequest) else { + return nil + } + + let operationQueue = OperationManagerFacade.sharedDefaultQueue + + let assetConversionAggregator = AssetConversionAggregationFactory( + chainRegistry: chainRegistry, + operationQueue: operationQueue + ) + + let extrinsicServiceFactory = ExtrinsicServiceFactory( + runtimeRegistry: runtimeService, + engine: connection, + operationManager: OperationManager(operationQueue: operationQueue) + ) + + let feeService = AssetHubFeeService( + wallet: wallet, + chainRegistry: chainRegistry, + operationQueue: operationQueue + ) + + let signingWrapper = SigningWrapperFactory().createSigningWrapper( + for: selectedAccount.metaId, + accountResponse: selectedAccount.chainAccount + ) + + let assetStorageFactory = AssetStorageInfoOperationFactory( + chainRegistry: chainRegistry, + operationQueue: operationQueue + ) + + let transactionStorage = SubstrateRepositoryFactory().createTxRepository() + let persistExtrinsicService = PersistentExtrinsicService( + repository: transactionStorage, + operationQueue: OperationManagerFacade.sharedDefaultQueue + ) + + let interactor = SwapConfirmInteractor( + initState: initState, + assetConversionFeeService: feeService, + assetConversionAggregator: assetConversionAggregator, + assetConversionExtrinsicService: AssetHubExtrinsicService(chain: chain), + chainRegistry: chainRegistry, + assetStorageFactory: assetStorageFactory, + runtimeService: runtimeService, + extrinsicServiceFactory: extrinsicServiceFactory, + priceLocalSubscriptionFactory: PriceProviderFactory.shared, + walletLocalSubscriptionFactory: WalletLocalSubscriptionFactory.shared, + generalLocalSubscriptionFactory: generalSubscriptonFactory, + persistExtrinsicService: persistExtrinsicService, + eventCenter: EventCenter.shared, + currencyManager: currencyManager, + selectedWallet: wallet, + operationQueue: operationQueue, + signer: signingWrapper + ) + + return interactor + } +} diff --git a/novawallet/Modules/Swaps/Confirm/SwapConfirmWireframe.swift b/novawallet/Modules/Swaps/Confirm/SwapConfirmWireframe.swift new file mode 100644 index 0000000000..ec59831f95 --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/SwapConfirmWireframe.swift @@ -0,0 +1,25 @@ +import Foundation + +final class SwapConfirmWireframe: SwapConfirmWireframeProtocol { + let completionClosure: SwapCompletionClosure? + + init(completionClosure: SwapCompletionClosure?) { + self.completionClosure = completionClosure + } + + func complete( + on view: ControllerBackedProtocol?, + payChainAsset: ChainAsset, + locale: Locale + ) { + let title = R.string.localizable + .commonTransactionSubmitted(preferredLanguages: locale.rLanguages) + + let presenter = view?.controller.navigationController?.presentingViewController + + presenter?.dismiss(animated: true) { + self.completionClosure?(payChainAsset) + self.presentSuccessNotification(title, from: presenter, completion: nil) + } + } +} diff --git a/novawallet/Modules/Swaps/Confirm/View/SwapConfirmViewLayout.swift b/novawallet/Modules/Swaps/Confirm/View/SwapConfirmViewLayout.swift new file mode 100644 index 0000000000..21bf6effa2 --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/View/SwapConfirmViewLayout.swift @@ -0,0 +1,115 @@ +import UIKit +import SoraUI + +final class SwapConfirmViewLayout: ScrollableContainerLayoutView { + let pairsView = SwapPairView() + + let detailsTableView: StackTableView = .create { + $0.cellHeight = 44 + $0.hasSeparators = true + $0.contentInsets = UIEdgeInsets(top: 0, left: 16, bottom: 4, right: 16) + } + + let rateCell: SwapInfoViewCell = .create { + $0.titleButton.imageWithTitleView?.titleColor = R.color.colorTextSecondary() + $0.titleButton.imageWithTitleView?.titleFont = .regularFootnote + } + + let priceDifferenceCell: SwapInfoViewCell = .create { + $0.titleButton.imageWithTitleView?.titleColor = R.color.colorTextSecondary() + $0.titleButton.imageWithTitleView?.titleFont = .regularFootnote + } + + let slippageCell: SwapInfoViewCell = .create { + $0.titleButton.imageWithTitleView?.titleColor = R.color.colorTextSecondary() + $0.titleButton.imageWithTitleView?.titleFont = .regularFootnote + } + + let networkFeeCell = SwapNetworkFeeViewCell() + + let walletTableView: StackTableView = .create { + $0.cellHeight = 44 + $0.hasSeparators = true + $0.contentInsets = UIEdgeInsets(top: 0, left: 16, bottom: 8, right: 16) + } + + let walletCell = StackTableCell() + + let accountCell: StackInfoTableCell = .create { + $0.detailsLabel.lineBreakMode = .byTruncatingMiddle + } + + private var warningView: InlineAlertView? + + private var notificationView: InlineAlertView? + + let loadableActionView = LoadableActionView() + + override func setupStyle() { + backgroundColor = R.color.colorSecondaryScreenBackground() + } + + override func setupLayout() { + super.setupLayout() + stackView.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 0, right: 16) + + addArrangedSubview(pairsView, spacingAfter: 8) + addArrangedSubview(detailsTableView, spacingAfter: 8) + detailsTableView.addArrangedSubview(rateCell) + detailsTableView.addArrangedSubview(priceDifferenceCell) + detailsTableView.addArrangedSubview(slippageCell) + detailsTableView.addArrangedSubview(networkFeeCell) + + addArrangedSubview(walletTableView, spacingAfter: 8) + walletTableView.addArrangedSubview(walletCell) + walletTableView.addArrangedSubview(accountCell) + + addSubview(loadableActionView) + loadableActionView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(UIConstants.horizontalInset) + make.bottom.equalTo(safeAreaLayoutGuide).inset(UIConstants.actionBottomInset) + make.height.equalTo(UIConstants.actionHeight) + } + } + + func setup(locale: Locale) { + slippageCell.titleButton.imageWithTitleView?.title = R.string.localizable.swapsSetupSlippage( + preferredLanguages: locale.rLanguages + ) + priceDifferenceCell.titleButton.imageWithTitleView?.title = R.string.localizable.swapsSetupPriceDifference( + preferredLanguages: locale.rLanguages + ) + rateCell.titleButton.imageWithTitleView?.title = R.string.localizable.swapsSetupDetailsRate( + preferredLanguages: locale.rLanguages) + networkFeeCell.titleButton.imageWithTitleView?.title = R.string.localizable.commonNetworkFee( + preferredLanguages: locale.rLanguages) + rateCell.titleButton.invalidateLayout() + networkFeeCell.titleButton.invalidateLayout() + + walletCell.titleLabel.text = R.string.localizable.commonWallet( + preferredLanguages: locale.rLanguages) + accountCell.titleLabel.text = R.string.localizable.commonAccount( + preferredLanguages: locale.rLanguages) + + loadableActionView.actionButton.imageWithTitleView?.title = R.string.localizable.commonConfirm( + preferredLanguages: locale.rLanguages) + } + + func set(warning: String?) { + applyWarning( + on: &warningView, + after: walletTableView, + text: warning, + spacing: 8 + ) + } + + func set(notification: String?) { + applyInfo( + on: ¬ificationView, + after: warningView ?? walletTableView, + text: notification, + spacing: 8 + ) + } +} diff --git a/novawallet/Modules/Swaps/Confirm/View/SwapElementView.swift b/novawallet/Modules/Swaps/Confirm/View/SwapElementView.swift new file mode 100644 index 0000000000..af94688bea --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/View/SwapElementView.swift @@ -0,0 +1,124 @@ +import UIKit +import SoraUI +import SnapKit + +final class SwapElementView: UIView { + var contentInsets: UIEdgeInsets = .init(top: 16, left: 12, bottom: 20, right: 12) { + didSet { + contentView.snp.updateConstraints { + $0.edges.equalToSuperview().inset(contentInsets) + } + } + } + + static let assetIconRadius: CGFloat = 24 + + let backgroundView: RoundedView = .create { + $0.apply(style: .roundedLightCell) + } + + let assetIconView: AssetIconView = .create { + $0.contentInsets = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 6) + $0.backgroundView.cornerRadius = SwapElementView.assetIconRadius + } + + let valueLabel: UILabel = .init( + style: .semiboldBodyPrimary, + textAlignment: .center, + numberOfLines: 1 + ) + + let priceLabel: UILabel = .init( + style: .footnoteSecondary, + textAlignment: .center, + numberOfLines: 1 + ) + + let hubIconNameView: IconDetailsView = .create { + $0.spacing = 8 + $0.iconWidth = 16 + $0.mode = .iconDetails + $0.detailsLabel.apply(style: .footnoteSecondary) + } + + private var hubImageViewModel: ImageViewModelProtocol? + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .clear + + setupLayout() + } + + lazy var contentView = UIView() + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + addSubview(backgroundView) + addSubview(contentView) + + contentView.addSubview(assetIconView) + contentView.addSubview(valueLabel) + contentView.addSubview(priceLabel) + contentView.addSubview(hubIconNameView) + + assetIconView.snp.makeConstraints { + $0.width.height.equalTo(48) + $0.top.centerX.equalToSuperview() + } + + valueLabel.snp.makeConstraints { + $0.top.equalTo(assetIconView.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview() + } + + priceLabel.snp.makeConstraints { + $0.top.equalTo(valueLabel.snp.bottom).offset(2) + $0.leading.trailing.equalToSuperview() + } + + hubIconNameView.snp.makeConstraints { + $0.top.equalTo(priceLabel.snp.bottom).offset(16) + $0.leading.greaterThanOrEqualToSuperview() + $0.trailing.lessThanOrEqualToSuperview() + $0.centerX.equalToSuperview().priority(.high) + $0.bottom.equalToSuperview() + } + + backgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + contentView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(contentInsets) + } + } +} + +extension SwapElementView { + func bind(viewModel: SwapAssetAmountViewModel) { + let width = 2 * Self.assetIconRadius - assetIconView.contentInsets.left - assetIconView.contentInsets.right + let height = 2 * Self.assetIconRadius - assetIconView.contentInsets.top - assetIconView.contentInsets.bottom + let size = CGSize(width: width, height: height) + assetIconView.bind(viewModel: viewModel.imageViewModel, size: size) + + viewModel.hub.icon?.cancel(on: hubIconNameView.imageView) + hubImageViewModel = viewModel.hub.icon + viewModel.hub.icon?.loadImage( + on: hubIconNameView.imageView, + targetSize: .init( + width: hubIconNameView.iconWidth, + height: hubIconNameView.iconWidth + ), + animated: true + ) + hubIconNameView.detailsLabel.text = viewModel.hub.name + valueLabel.text = viewModel.amount + priceLabel.text = viewModel.price ?? " " + } +} diff --git a/novawallet/Modules/Swaps/Confirm/View/SwapPairView.swift b/novawallet/Modules/Swaps/Confirm/View/SwapPairView.swift new file mode 100644 index 0000000000..1f032d8e71 --- /dev/null +++ b/novawallet/Modules/Swaps/Confirm/View/SwapPairView.swift @@ -0,0 +1,55 @@ +import UIKit +import SoraUI + +final class SwapPairView: UIView { + let leftAssetView = SwapElementView() + let rigthAssetView = SwapElementView() + + let arrowView: RoundedButton = .create { + $0.imageWithTitleView?.iconImage = R.image.iconForward() + $0.roundedBackgroundView?.apply(style: .icon) + $0.roundedBackgroundView?.fillColor = R.color.colorSecondaryScreenBackground()! + $0.roundedBackgroundView?.cornerRadius = 24 + $0.isUserInteractionEnabled = false + } + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .clear + + setupLayout() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + let stackView = UIView.hStack(distribution: .fillEqually, spacing: 8, [ + leftAssetView, + rigthAssetView + ]) + addSubview(stackView) + addSubview(arrowView) + + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + arrowView.snp.makeConstraints { + $0.center.equalTo(stackView.snp.center) + } + } + + override var intrinsicContentSize: CGSize { + CGSize( + width: UIView.noIntrinsicMetric, + height: max( + leftAssetView.intrinsicContentSize.height, + rigthAssetView.intrinsicContentSize.height + ) + ) + } +} diff --git a/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsInteractor.swift b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsInteractor.swift new file mode 100644 index 0000000000..ed260b50d3 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsInteractor.swift @@ -0,0 +1,127 @@ +import Foundation + +final class GetTokenOptionsInteractor { + weak var presenter: GetTokenOptionsInteractorOutputProtocol? + + let selectedWallet: MetaAccountModel + let assetModelObservable: AssetListModelObservable + let destinationChainAsset: ChainAsset + let xcmTransfersSyncService: XcmTransfersSyncServiceProtocol + let purchaseProvider: PurchaseProviderProtocol + let logger: LoggerProtocol + + private var xcmTransfers: XcmTransfers? + + init( + selectedWallet: MetaAccountModel, + destinationChainAsset: ChainAsset, + assetModelObservable: AssetListModelObservable, + xcmTransfersSyncService: XcmTransfersSyncServiceProtocol, + purchaseProvider: PurchaseProviderProtocol, + logger: LoggerProtocol + ) { + self.selectedWallet = selectedWallet + self.destinationChainAsset = destinationChainAsset + self.assetModelObservable = assetModelObservable + self.xcmTransfersSyncService = xcmTransfersSyncService + self.purchaseProvider = purchaseProvider + self.logger = logger + } + + deinit { + xcmTransfersSyncService.throttle() + } + + private func provideModel() { + let accountRequest = destinationChainAsset.chain.accountRequest() + + guard let selectedAccount = selectedWallet.fetchMetaChainAccount(for: accountRequest) else { + presenter?.didReceive(model: .empty) + return + } + + let availableXcmOrigins = determineAvailableXcmOrigins() + let purchaseActions = purchaseProvider.buildPurchaseActions( + for: destinationChainAsset, + accountId: selectedAccount.chainAccount.accountId + ) + + let receiveAvailable = TokenOperation.checkReceiveOperationAvailable( + walletType: selectedWallet.type, + chainAsset: destinationChainAsset + ).available + + let buyAvailable = TokenOperation.checkBuyOperationAvailable( + purchaseActions: purchaseActions, + walletType: selectedWallet.type, + chainAsset: destinationChainAsset + ).available + + let model = GetTokenOptionsModel( + availableXcmOrigins: availableXcmOrigins, + xcmTransfers: xcmTransfers, + receiveAccount: receiveAvailable ? selectedAccount : nil, + buyOptions: buyAvailable ? purchaseActions : [] + ) + + presenter?.didReceive(model: model) + } + + private func determineAvailableXcmOrigins() -> [ChainAsset] { + guard let xcmTransfers = xcmTransfers else { + return [] + } + + let balances = assetModelObservable.state.value.balances + let chains = assetModelObservable.state.value.allChains + + let availableOrigins = xcmTransfers + .transferChainAssets(to: destinationChainAsset.chainAssetId) + .compactMap { chainAssetId in + if + case let .success(balance) = balances[chainAssetId], + balance.transferable > 0, + let chain = chains[chainAssetId.chainId], + let asset = chain.asset(for: chainAssetId.assetId) { + return (ChainAsset(chain: chain, asset: asset), balance.transferable) + } else { + return nil + } + } + .sorted { balance1, balance2 in + balance1.1 > balance2.1 + } + .map(\.0) + + return availableOrigins + } + + private func setupBalances() { + assetModelObservable.addObserver(with: self, queue: .main) { [weak self] _, _ in + self?.provideModel() + } + } + + private func setupXcms() { + xcmTransfersSyncService.notificationCallback = { [weak self] result in + switch result { + case let .success(xcmTransfers): + self?.xcmTransfers = xcmTransfers + self?.provideModel() + case let .failure(error): + self?.logger.error("Xcm sync failed: \(error)") + } + } + + xcmTransfersSyncService.setup() + } +} + +extension GetTokenOptionsInteractor: GetTokenOptionsInteractorInputProtocol { + func setup() { + setupBalances() + setupXcms() + + provideModel() + } +} diff --git a/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsPresenter.swift b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsPresenter.swift new file mode 100644 index 0000000000..78546f19be --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsPresenter.swift @@ -0,0 +1,95 @@ +import Foundation +import SoraFoundation + +final class GetTokenOptionsPresenter { + weak var view: GetTokenOptionsViewProtocol? + let interactor: GetTokenOptionsInteractorInputProtocol + let wireframe: GetTokenOptionsWireframeProtocol + let destinationChainAsset: ChainAsset + + let allOperations: [GetTokenOperation] = [.crosschain, .receive, .buy] + + private var model: GetTokenOptionsModel? + + init( + interactor: GetTokenOptionsInteractorInputProtocol, + wireframe: GetTokenOptionsWireframeProtocol, + destinationChainAsset: ChainAsset + ) { + self.interactor = interactor + self.wireframe = wireframe + self.destinationChainAsset = destinationChainAsset + } + + private func isOperationAvailable(_ operation: GetTokenOperation) -> Bool { + guard let model = model else { + return false + } + + switch operation { + case .crosschain: + return !model.availableXcmOrigins.isEmpty + case .receive: + return model.receiveAccount != nil + case .buy: + return !model.buyOptions.isEmpty + } + } + + private func provideViewModel() { + let viewModels = allOperations.map { operation in + let isActive = isOperationAvailable(operation) + let token = destinationChainAsset.asset.symbol + + return LocalizableResource { locale in + TokenOperationTableViewCell.Model( + content: .init( + title: operation.titleForLocale(locale), + subtitle: operation.subtitleForLocale(locale, token: token), + icon: operation.icon + ), + isActive: isActive + ) + } + } + + view?.didReceive(viewModels: viewModels) + } +} + +extension GetTokenOptionsPresenter: GetTokenOptionsPresenterProtocol { + func setup() { + provideViewModel() + + interactor.setup() + } + + func selectOption(at index: Int) { + guard let model = model else { + return + } + + switch allOperations[index] { + case .crosschain: + if let xcmTransfers = model.xcmTransfers { + wireframe.complete( + on: view, + result: .crosschains(model.availableXcmOrigins, xcmTransfers) + ) + } + case .receive: + if let account = model.receiveAccount { + wireframe.complete(on: view, result: .receive(account)) + } + case .buy: + wireframe.complete(on: view, result: .buy(model.buyOptions)) + } + } +} + +extension GetTokenOptionsPresenter: GetTokenOptionsInteractorOutputProtocol { + func didReceive(model: GetTokenOptionsModel) { + self.model = model + provideViewModel() + } +} diff --git a/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsProtocols.swift b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsProtocols.swift new file mode 100644 index 0000000000..cee619f526 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsProtocols.swift @@ -0,0 +1,23 @@ +import Foundation +import SoraFoundation + +protocol GetTokenOptionsViewProtocol: ControllerBackedProtocol { + func didReceive(viewModels: [LocalizableResource]) +} + +protocol GetTokenOptionsPresenterProtocol: AnyObject { + func setup() + func selectOption(at index: Int) +} + +protocol GetTokenOptionsWireframeProtocol: AnyObject { + func complete(on view: GetTokenOptionsViewProtocol?, result: GetTokenOptionsResult) +} + +protocol GetTokenOptionsInteractorInputProtocol: AnyObject { + func setup() +} + +protocol GetTokenOptionsInteractorOutputProtocol: AnyObject { + func didReceive(model: GetTokenOptionsModel) +} diff --git a/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsViewController.swift b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsViewController.swift new file mode 100644 index 0000000000..71f02f224c --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsViewController.swift @@ -0,0 +1,41 @@ +import UIKit +import SoraFoundation + +final class GetTokenOptionsViewController: ModalPickerViewController< + TokenOperationTableViewCell, + TokenOperationTableViewCell.Model +> { + let operationPresenter: GetTokenOptionsPresenterProtocol + + init(operationPresenter: GetTokenOptionsPresenterProtocol) { + self.operationPresenter = operationPresenter + + let nib = R.nib.modalPickerViewController + super.init(nibName: nib.name, bundle: nib.bundle) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + operationPresenter.setup() + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + operationPresenter.selectOption(at: indexPath.row) + } +} + +extension GetTokenOptionsViewController: GetTokenOptionsViewProtocol { + func didReceive(viewModels: [LocalizableResource]) { + self.viewModels = viewModels + + reload() + } +} diff --git a/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsViewFactory.swift b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsViewFactory.swift new file mode 100644 index 0000000000..9ef0e259e6 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsViewFactory.swift @@ -0,0 +1,78 @@ +import UIKit +import SoraUI +import SoraFoundation + +enum GetTokenOptionsViewFactory { + static func createView( + from destinationChainAsset: ChainAsset, + assetModelObservable: AssetListModelObservable, + completion: @escaping GetTokenOptionsCompletion + ) -> GetTokenOptionsViewProtocol? { + guard let interactor = createInteractor( + from: destinationChainAsset, + assetModelObservable: assetModelObservable + ) else { + return nil + } + + let wireframe = GetTokenOptionsWireframe(completion: completion) + + let presenter = GetTokenOptionsPresenter( + interactor: interactor, + wireframe: wireframe, + destinationChainAsset: destinationChainAsset + ) + + let view = GetTokenOptionsViewController(operationPresenter: presenter) + + view.localizedTitle = .init { + R.string.localizable.swapsSetupDepositTitle( + destinationChainAsset.asset.symbol, + preferredLanguages: $0.rLanguages + ) + } + + view.selectedIndex = NSNotFound + view.modalPresentationStyle = .custom + view.headerBorderType = .none + view.separatorStyle = .none + view.separatorColor = R.color.colorDivider() + view.cellHeight = 48 + + let factory = ModalSheetPresentationFactory(configuration: ModalSheetPresentationConfiguration.nova) + view.modalTransitioningFactory = factory + + let height = view.headerHeight + CGFloat(presenter.allOperations.count) * view.cellHeight + view.footerHeight + view.preferredContentSize = CGSize(width: 0.0, height: height) + + view.localizationManager = LocalizationManager.shared + + presenter.view = view + interactor.presenter = presenter + + return view + } + + private static func createInteractor( + from destinationChainAsset: ChainAsset, + assetModelObservable: AssetListModelObservable + ) -> GetTokenOptionsInteractor? { + guard let selectedWallet = SelectedWalletSettings.shared.value else { + return nil + } + + let xcmTransfersSyncService = XcmTransfersSyncService( + remoteUrl: ApplicationConfig.shared.xcmTransfersURL, + operationQueue: OperationManagerFacade.sharedDefaultQueue + ) + + return GetTokenOptionsInteractor( + selectedWallet: selectedWallet, + destinationChainAsset: destinationChainAsset, + assetModelObservable: assetModelObservable, + xcmTransfersSyncService: xcmTransfersSyncService, + purchaseProvider: PurchaseAggregator.defaultAggregator(), + logger: Logger.shared + ) + } +} diff --git a/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsWireframe.swift b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsWireframe.swift new file mode 100644 index 0000000000..a7b5c6c962 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/GetTokenOptions/GetTokenOptionsWireframe.swift @@ -0,0 +1,25 @@ +import Foundation + +final class GetTokenOptionsWireframe { + let completion: GetTokenOptionsCompletion? + + init(completion: GetTokenOptionsCompletion?) { + self.completion = completion + } + + func complete( + on view: GetTokenOptionsViewProtocol?, + completion: GetTokenOptionsCompletion?, + result: GetTokenOptionsResult + ) { + view?.controller.dismiss(animated: true) { + completion?(result) + } + } +} + +extension GetTokenOptionsWireframe: GetTokenOptionsWireframeProtocol { + func complete(on view: GetTokenOptionsViewProtocol?, result: GetTokenOptionsResult) { + complete(on: view, completion: completion, result: result) + } +} diff --git a/novawallet/Modules/Swaps/Setup/GetTokenOptions/Model/GetTokenOperation.swift b/novawallet/Modules/Swaps/Setup/GetTokenOptions/Model/GetTokenOperation.swift new file mode 100644 index 0000000000..6c2f5ebe2e --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/GetTokenOptions/Model/GetTokenOperation.swift @@ -0,0 +1,58 @@ +import Foundation +import UIKit + +enum GetTokenOperation { + case crosschain + case receive + case buy +} + +extension GetTokenOperation { + func titleForLocale(_ locale: Locale) -> String { + switch self { + case .crosschain: + return R.string.localizable.swapsSetupDepositByCrossChainTransferTitle( + preferredLanguages: locale.rLanguages + ) + case .receive: + return R.string.localizable.walletAssetReceive( + preferredLanguages: locale.rLanguages + ) + case .buy: + return R.string.localizable.walletAssetBuy( + preferredLanguages: locale.rLanguages + ) + } + } + + func subtitleForLocale(_ locale: Locale, token: String) -> String { + switch self { + case .crosschain: + return R.string.localizable.swapsSetupDepositByCrossChainTransferSubtitle( + token, + preferredLanguages: locale.rLanguages + ) + case .receive: + return R.string.localizable.swapsSetupDepositByReceiveSubtitle( + token, + preferredLanguages: locale.rLanguages + ) + case .buy: + return R.string.localizable.swapsSetupDepositByBuySubtitle( + token, + preferredLanguages: locale.rLanguages + ) + } + } + + var icon: UIImage? { + switch self { + case .crosschain: + return R.image.iconCrossChainTransfer() + case .receive: + return R.image.iconReceive() + case .buy: + return R.image.iconBuy() + } + } +} diff --git a/novawallet/Modules/Swaps/Setup/GetTokenOptions/Model/GetTokenOptionsModel.swift b/novawallet/Modules/Swaps/Setup/GetTokenOptions/Model/GetTokenOptionsModel.swift new file mode 100644 index 0000000000..aa9343e447 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/GetTokenOptions/Model/GetTokenOptionsModel.swift @@ -0,0 +1,19 @@ +import Foundation + +struct GetTokenOptionsModel { + let availableXcmOrigins: [ChainAsset] + let xcmTransfers: XcmTransfers? + let receiveAccount: MetaChainAccountResponse? + let buyOptions: [PurchaseAction] +} + +extension GetTokenOptionsModel { + static var empty: GetTokenOptionsModel { + .init( + availableXcmOrigins: [], + xcmTransfers: nil, + receiveAccount: nil, + buyOptions: [] + ) + } +} diff --git a/novawallet/Modules/Swaps/Setup/GetTokenOptions/Model/GetTokenOptionsResult.swift b/novawallet/Modules/Swaps/Setup/GetTokenOptions/Model/GetTokenOptionsResult.swift new file mode 100644 index 0000000000..e4fd320bbc --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/GetTokenOptions/Model/GetTokenOptionsResult.swift @@ -0,0 +1,9 @@ +import Foundation + +enum GetTokenOptionsResult { + case crosschains([ChainAsset], XcmTransfers) + case receive(MetaChainAccountResponse) + case buy([PurchaseAction]) +} + +typealias GetTokenOptionsCompletion = (GetTokenOptionsResult) -> Void diff --git a/novawallet/Modules/Swaps/Setup/Model/SwapIssueCheckParams.swift b/novawallet/Modules/Swaps/Setup/Model/SwapIssueCheckParams.swift new file mode 100644 index 0000000000..f2f55686c4 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/Model/SwapIssueCheckParams.swift @@ -0,0 +1,13 @@ +import Foundation + +struct SwapIssueCheckParams { + let payChainAsset: ChainAsset? + let receiveChainAsset: ChainAsset? + let payAmount: Decimal? + let receiveAmount: Decimal? + let payAssetBalance: AssetBalance? + let receiveAssetBalance: AssetBalance? + let payAssetExistense: AssetBalanceExistence? + let receiveAssetExistense: AssetBalanceExistence? + let quoteResult: Result? +} diff --git a/novawallet/Modules/Swaps/Setup/Model/SwapIssueViewModelFactory.swift b/novawallet/Modules/Swaps/Setup/Model/SwapIssueViewModelFactory.swift new file mode 100644 index 0000000000..371f75d4d8 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/Model/SwapIssueViewModelFactory.swift @@ -0,0 +1,85 @@ +import Foundation + +protocol SwapIssueViewModelFactoryProtocol { + func detectIssues(in model: SwapIssueCheckParams, locale: Locale) -> [SwapSetupViewIssue] +} + +final class SwapIssueViewModelFactory { + let balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol + + init(balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol) { + self.balanceViewModelFactoryFacade = balanceViewModelFactoryFacade + } + + func detectZeroBalance(in model: SwapIssueCheckParams) -> SwapSetupViewIssue? { + if let balance = model.payAssetBalance?.transferable, balance == 0 { + return .zeroBalance + } else { + return nil + } + } + + func detectZeroReceiveAmount(in model: SwapIssueCheckParams) -> SwapSetupViewIssue? { + if let receiveAmount = model.receiveAmount, receiveAmount == 0 { + return .zeroReceiveAmount + } else { + return nil + } + } + + func detectInsufficientBalance(in model: SwapIssueCheckParams) -> SwapSetupViewIssue? { + if let payAmount = model.payAmount, + let payChainAsset = model.payChainAsset, + let balance = model.payAssetBalance?.transferable.decimal(precision: payChainAsset.asset.precision), + payAmount > balance { + return .insufficientBalance + } else { + return nil + } + } + + func detectMinBalanceViolationOnReceive(in model: SwapIssueCheckParams, locale: Locale) -> SwapSetupViewIssue? { + guard + let receiveChainAsset = model.receiveChainAsset, + let receiveAmount = model.receiveAmount, + let minBalance = model.receiveAssetExistense?.minBalance.decimal( + precision: receiveChainAsset.asset.precision + ), + let beforeSwapBalance = model.receiveAssetBalance?.freeInPlank.decimal( + precision: receiveChainAsset.asset.precision + ) else { + return nil + } + + guard beforeSwapBalance + receiveAmount < minBalance else { + return nil + } + + let minBalanceString = balanceViewModelFactoryFacade.amountFromValue( + targetAssetInfo: receiveChainAsset.assetDisplayInfo, + value: minBalance + ).value(for: locale) + + return .minBalanceViolation(minBalanceString) + } + + func detectNoLiquidity(in model: SwapIssueCheckParams) -> SwapSetupViewIssue? { + if case .failure = model.quoteResult { + return .noLiqudity + } else { + return nil + } + } +} + +extension SwapIssueViewModelFactory: SwapIssueViewModelFactoryProtocol { + func detectIssues(in model: SwapIssueCheckParams, locale: Locale) -> [SwapSetupViewIssue] { + [ + detectZeroBalance(in: model), + detectZeroReceiveAmount(in: model), + detectInsufficientBalance(in: model), + detectMinBalanceViolationOnReceive(in: model, locale: locale), + detectNoLiquidity(in: model) + ].compactMap { $0 } + } +} diff --git a/novawallet/Modules/Swaps/Setup/Model/SwapModels.swift b/novawallet/Modules/Swaps/Setup/Model/SwapModels.swift new file mode 100644 index 0000000000..ae33aa71ef --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/Model/SwapModels.swift @@ -0,0 +1,83 @@ +import SoraFoundation + +struct SwapSetupInitState { + let payChainAsset: ChainAsset? + let receiveChainAsset: ChainAsset? + let feeChainAsset: ChainAsset? + let amount: Decimal? + let direction: AssetConversion.Direction? + + init( + payChainAsset: ChainAsset?, + receiveChainAsset: ChainAsset? = nil, + feeChainAsset: ChainAsset? = nil, + amount: Decimal? = nil, + direction: AssetConversion.Direction? = nil + ) { + self.payChainAsset = payChainAsset + self.receiveChainAsset = receiveChainAsset + self.feeChainAsset = feeChainAsset + self.amount = amount + self.direction = direction + } +} + +struct SwapSetupFeeIdentifier: Equatable { + let transactionId: String + let feeChainAssetId: ChainAssetId? +} + +struct DepositOperationModel { + let operation: TokenOperation + let active: Bool +} + +extension DepositOperationModel { + func titleForLocale(_ locale: Locale) -> String { + switch operation { + case .send: + return R.string.localizable.swapsSetupDepositByCrossChainTransferTitle( + preferredLanguages: locale.rLanguages + ) + case .receive: + return R.string.localizable.walletAssetReceive( + preferredLanguages: locale.rLanguages + ) + case .buy: + return R.string.localizable.walletAssetBuy( + preferredLanguages: locale.rLanguages + ) + } + } + + func subtitleForLocale(_ locale: Locale, token: String) -> String { + switch operation { + case .send: + return R.string.localizable.swapsSetupDepositByCrossChainTransferSubtitle( + token, + preferredLanguages: locale.rLanguages + ) + case .receive: + return R.string.localizable.swapsSetupDepositByReceiveSubtitle( + token, + preferredLanguages: locale.rLanguages + ) + case .buy: + return R.string.localizable.swapsSetupDepositByBuySubtitle( + token, + preferredLanguages: locale.rLanguages + ) + } + } + + var icon: UIImage? { + switch operation { + case .send: + return R.image.iconCrossChainTransfer() + case .receive: + return R.image.iconReceive() + case .buy: + return R.image.iconBuy() + } + } +} diff --git a/novawallet/Modules/Swaps/Setup/Model/SwapViewModels.swift b/novawallet/Modules/Swaps/Setup/Model/SwapViewModels.swift new file mode 100644 index 0000000000..ed061e0a90 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/Model/SwapViewModels.swift @@ -0,0 +1,58 @@ +import SoraFoundation + +struct SwapsAssetViewModel { + let symbol: String + let imageViewModel: ImageViewModelProtocol? + let hub: NetworkViewModel +} + +struct EmptySwapsAssetViewModel { + let imageViewModel: ImageViewModelProtocol? + let title: String + let subtitle: String +} + +enum SwapAssetInputViewModel { + case asset(SwapsAssetViewModel) + case empty(EmptySwapsAssetViewModel) +} + +struct SwapFeeViewModel { + var isEditable: Bool + var balanceViewModel: BalanceViewModelProtocol +} + +enum TextFieldFocus { + case payAsset + case receiveAsset +} + +struct SwapPriceDifferenceViewModel { + let price: String? + let difference: DifferenceViewModel? +} + +enum FeeSelectionViewModel: Int, CaseIterable { + case utilityAsset + case payAsset +} + +extension FeeSelectionViewModel { + static var title = LocalizableResource { + R.string.localizable.commonNetworkFee( + preferredLanguages: $0.rLanguages + ) + } + + static var message = LocalizableResource { + R.string.localizable.swapsSetupNetworkFeeTokenTitle( + preferredLanguages: $0.rLanguages + ) + } + + static var hint = LocalizableResource { + R.string.localizable.swapsSetupNetworkFeeTokenHint( + preferredLanguages: $0.rLanguages + ) + } +} diff --git a/novawallet/Modules/Swaps/Setup/Model/SwapsSetupViewModelFactory.swift b/novawallet/Modules/Swaps/Setup/Model/SwapsSetupViewModelFactory.swift new file mode 100644 index 0000000000..a388cff814 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/Model/SwapsSetupViewModelFactory.swift @@ -0,0 +1,252 @@ +import SoraFoundation +import BigInt + +protocol SwapsSetupViewModelFactoryProtocol: SwapBaseViewModelFactoryProtocol, SwapIssueViewModelFactoryProtocol { + func buttonState(for issueParams: SwapIssueCheckParams, locale: Locale) -> ButtonState + + func payTitleViewModel( + assetDisplayInfo: AssetBalanceDisplayInfo?, + maxValue: BigUInt?, + locale: Locale + ) -> TitleHorizontalMultiValueView.Model + + func payAssetViewModel(chainAsset: ChainAsset?, locale: Locale) -> SwapAssetInputViewModel + + func inputPriceViewModel( + assetDisplayInfo: AssetBalanceDisplayInfo, + amount: Decimal?, + priceData: PriceData?, + locale: Locale + ) -> String? + + func receiveTitleViewModel(for locale: Locale) -> TitleHorizontalMultiValueView.Model + func receiveAssetViewModel(chainAsset: ChainAsset?, locale: Locale) -> SwapAssetInputViewModel + + func amountInputViewModel( + chainAsset: ChainAsset, + amount: Decimal?, + locale: Locale + ) -> AmountInputViewModelProtocol + + func feeViewModel( + amount: BigUInt, + assetDisplayInfo: AssetBalanceDisplayInfo, + isEditable: Bool, + priceData: PriceData?, + locale: Locale + ) -> SwapFeeViewModel + + func amountFromValue(_ decimal: Decimal, chainAsset: ChainAsset, locale: Locale) -> String +} + +final class SwapsSetupViewModelFactory: SwapBaseViewModelFactory { + let issuesViewModelFactory: SwapIssueViewModelFactoryProtocol + let networkViewModelFactory: NetworkViewModelFactoryProtocol + + init( + balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol, + issuesViewModelFactory: SwapIssueViewModelFactoryProtocol, + networkViewModelFactory: NetworkViewModelFactoryProtocol, + percentForamatter: LocalizableResource, + priceDifferenceConfig: SwapPriceDifferenceConfig + ) { + self.issuesViewModelFactory = issuesViewModelFactory + self.networkViewModelFactory = networkViewModelFactory + + super.init( + balanceViewModelFactoryFacade: balanceViewModelFactoryFacade, + percentForamatter: percentForamatter, + priceDifferenceConfig: priceDifferenceConfig + ) + } + + private static func buttonTitle( + params: SwapIssueCheckParams, + hasIssues: Bool, + locale: Locale + ) -> String { + switch (params.payChainAsset, params.receiveChainAsset) { + case (nil, nil), (nil, _): + return R.string.localizable.swapsSetupAssetActionSelectPay(preferredLanguages: locale.rLanguages) + case (_, nil): + return R.string.localizable.swapsSetupAssetActionSelectReceive(preferredLanguages: locale.rLanguages) + default: + if params.payAmount == nil || params.receiveAmount == nil || hasIssues { + return R.string.localizable.swapsSetupAssetActionEnterAmount(preferredLanguages: locale.rLanguages) + } else { + return R.string.localizable.commonContinue(preferredLanguages: locale.rLanguages) + } + } + } + + private func assetViewModel(chainAsset: ChainAsset) -> SwapsAssetViewModel { + let networkViewModel = networkViewModelFactory.createViewModel(from: chainAsset.chain) + let assetIcon: ImageViewModelProtocol = chainAsset.asset.icon.map { RemoteImageViewModel(url: $0) } ?? + StaticImageViewModel(image: R.image.iconDefaultToken()!) + + return SwapsAssetViewModel( + symbol: chainAsset.asset.symbol, + imageViewModel: assetIcon, + hub: networkViewModel + ) + } + + private func emptyPayAssetViewModel(for locale: Locale) -> EmptySwapsAssetViewModel { + EmptySwapsAssetViewModel( + imageViewModel: StaticImageViewModel(image: R.image.iconAddSwapAmount()!), + title: R.string.localizable.swapsSetupAssetPayTitle(preferredLanguages: locale.rLanguages), + subtitle: R.string.localizable.swapsSetupAssetSelectSubtitle(preferredLanguages: locale.rLanguages) + ) + } + + private func emptyReceiveAssetViewModel(for locale: Locale) -> EmptySwapsAssetViewModel { + EmptySwapsAssetViewModel( + imageViewModel: StaticImageViewModel(image: R.image.iconAddSwapAmount()!), + title: R.string.localizable.swapsSetupAssetReceiveTitle(preferredLanguages: locale.rLanguages), + subtitle: R.string.localizable.swapsSetupAssetSelectSubtitle(preferredLanguages: locale.rLanguages) + ) + } + + override func formatPriceDifference(amount: Decimal, locale: Locale) -> String { + percentForamatter.value(for: locale).stringFromDecimal(amount)?.inParenthesis() ?? "" + } +} + +extension SwapsSetupViewModelFactory: SwapsSetupViewModelFactoryProtocol { + func buttonState(for issueParams: SwapIssueCheckParams, locale: Locale) -> ButtonState { + let dataFullFilled = issueParams.payChainAsset != nil && + issueParams.receiveChainAsset != nil && + issueParams.payAmount != nil && issueParams.receiveAmount != nil + + let hasIssues = !issuesViewModelFactory.detectIssues(in: issueParams, locale: locale).isEmpty + + return .init( + title: .init { + Self.buttonTitle( + params: issueParams, + hasIssues: hasIssues, + locale: $0 + ) + }, + enabled: dataFullFilled && !hasIssues + ) + } + + func payTitleViewModel( + assetDisplayInfo: AssetBalanceDisplayInfo?, + maxValue: BigUInt?, + locale: Locale + ) -> TitleHorizontalMultiValueView.Model { + let title = R.string.localizable.swapsSetupAssetSelectPayTitle( + preferredLanguages: locale.rLanguages + ) + + if let assetDisplayInfo = assetDisplayInfo, let maxValue = maxValue { + let amountDecimal = Decimal.fromSubstrateAmount( + maxValue, + precision: Int16(assetDisplayInfo.assetPrecision) + ) ?? 0 + let maxValueString = balanceViewModelFactoryFacade.amountFromValue( + targetAssetInfo: assetDisplayInfo, + value: amountDecimal + ).value(for: locale) + + return .init( + title: title, + subtitle: + R.string.localizable.swapsSetupAssetMax( + preferredLanguages: locale.rLanguages + ), + value: maxValueString + ) + } else { + return .init( + title: + R.string.localizable.swapsSetupAssetSelectPayTitle( + preferredLanguages: locale.rLanguages + ), + subtitle: "", + value: "" + ) + } + } + + func payAssetViewModel(chainAsset: ChainAsset?, locale: Locale) -> SwapAssetInputViewModel { + chainAsset.map { .asset(assetViewModel(chainAsset: $0)) } ?? + .empty(emptyPayAssetViewModel(for: locale)) + } + + func inputPriceViewModel( + assetDisplayInfo: AssetBalanceDisplayInfo, + amount: Decimal?, + priceData: PriceData?, + locale: Locale + ) -> String? { + guard + let amount = amount, + let priceData = priceData else { + return nil + } + return balanceViewModelFactoryFacade.priceFromAmount( + targetAssetInfo: assetDisplayInfo, + amount: amount, + priceData: priceData + ).value(for: locale) + } + + func receiveTitleViewModel(for locale: Locale) -> TitleHorizontalMultiValueView.Model { + TitleHorizontalMultiValueView.Model( + title: + R.string.localizable.swapsSetupAssetSelectReceiveTitle(preferredLanguages: locale.rLanguages), + subtitle: "", + value: "" + ) + } + + func receiveAssetViewModel(chainAsset: ChainAsset?, locale: Locale) -> SwapAssetInputViewModel { + chainAsset.map { .asset(assetViewModel(chainAsset: $0)) } ?? + .empty(emptyReceiveAssetViewModel(for: locale)) + } + + func amountInputViewModel( + chainAsset: ChainAsset, + amount: Decimal?, + locale: Locale + ) -> AmountInputViewModelProtocol { + balanceViewModelFactoryFacade.createBalanceInputViewModel( + targetAssetInfo: chainAsset.assetDisplayInfo, + amount: amount + ).value(for: locale) + } + + func feeViewModel( + amount: BigUInt, + assetDisplayInfo: AssetBalanceDisplayInfo, + isEditable: Bool, + priceData: PriceData?, + locale: Locale + ) -> SwapFeeViewModel { + let amountDecimal = Decimal.fromSubstrateAmount( + amount, + precision: assetDisplayInfo.assetPrecision + ) ?? 0 + let balanceViewModel = balanceViewModelFactoryFacade.balanceFromPrice( + targetAssetInfo: assetDisplayInfo, + amount: amountDecimal, + priceData: priceData + ).value(for: locale) + + return .init(isEditable: isEditable, balanceViewModel: balanceViewModel) + } + + func amountFromValue(_ decimal: Decimal, chainAsset: ChainAsset, locale: Locale) -> String { + balanceViewModelFactoryFacade.amountFromValue( + targetAssetInfo: chainAsset.assetDisplayInfo, + value: decimal + ).value(for: locale) + } + + func detectIssues(in model: SwapIssueCheckParams, locale: Locale) -> [SwapSetupViewIssue] { + issuesViewModelFactory.detectIssues(in: model, locale: locale) + } +} diff --git a/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetLayout.swift b/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetLayout.swift new file mode 100644 index 0000000000..bbc417c169 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetLayout.swift @@ -0,0 +1,100 @@ +import UIKit + +final class SwapNetworkFeeSheetLayout: UIView { + let titleLabel: UILabel = .create { + $0.apply(style: .bottomSheetTitle) + $0.numberOfLines = 0 + } + + let detailsLabel: UILabel = .create { + $0.apply(style: .footnoteSecondary) + $0.numberOfLines = 0 + } + + let feeTypeSwitch: RoundedSegmentedControl = .create { + $0.backgroundView.fillColor = R.color.colorSegmentedBackgroundOnBlack()! + $0.selectionColor = R.color.colorSegmentedTabActive()! + $0.titleFont = .regularFootnote + $0.selectedTitleColor = R.color.colorTextPrimary()! + $0.titleColor = R.color.colorTextSecondary()! + $0.selectionCornerRadius = 10 + } + + let hint: IconDetailsView = .hint() + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = R.color.colorBottomSheetBackground() + + setupLayout() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + let stackView = UIView.vStack([ + titleLabel, + detailsLabel, + feeTypeSwitch, + hint + ]) + + stackView.setCustomSpacing(Constants.titleDetailsOffset, after: titleLabel) + stackView.setCustomSpacing(Constants.detailsSwitchOffset, after: detailsLabel) + stackView.setCustomSpacing(Constants.switchHintOffset, after: feeTypeSwitch) + + addSubview(stackView) + stackView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.top.equalToSuperview().inset(Constants.topOffset) + } + + feeTypeSwitch.snp.makeConstraints { make in + make.height.equalTo(Constants.controlHeight) + } + } +} + +extension SwapNetworkFeeSheetLayout { + enum Constants { + static let titleDetailsOffset: CGFloat = 18 + static let detailsSwitchOffset: CGFloat = 10 + static let switchHintOffset: CGFloat = 16 + static let topOffset: CGFloat = 10 + static let bottomOffset: CGFloat = 8 + static let controlHeight: CGFloat = 40 + } +} + +extension SwapNetworkFeeSheetLayout { + func contentHeight(model: SwapNetworkFeeSheetViewModel, locale: Locale) -> CGFloat { + let titleHeight = height(for: titleLabel, with: model.title.value(for: locale)) + let messageHeight = height(for: detailsLabel, with: model.message.value(for: locale)) + let hintHeight = height(for: hint.detailsLabel, with: model.hint.value(for: locale)) + + let vOffsets = Constants.topOffset + Constants.titleDetailsOffset + + Constants.detailsSwitchOffset + Constants.switchHintOffset + Constants.bottomOffset + + return vOffsets + titleHeight + messageHeight + Constants.controlHeight + hintHeight + } + + private func height(for label: UILabel, with text: String) -> CGFloat { + guard let font = label.font else { + return 0 + } + + let width = UIScreen.main.bounds.width - UIConstants.horizontalInset * 2 + let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude) + let boundingBox = text.boundingRect( + with: constraintRect, + options: .usesLineFragmentOrigin, + attributes: [.font: font], + context: nil + ) + return boundingBox.height + } +} diff --git a/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetViewController.swift b/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetViewController.swift new file mode 100644 index 0000000000..d661a3a7e9 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetViewController.swift @@ -0,0 +1,77 @@ +import UIKit +import SoraFoundation +import SoraUI + +final class SwapNetworkFeeSheetViewController: UIViewController, ViewHolder { + typealias RootViewType = SwapNetworkFeeSheetLayout + + let presenter: MessageSheetPresenterProtocol + let viewModel: SwapNetworkFeeSheetViewModel + + init( + presenter: MessageSheetPresenterProtocol, + viewModel: SwapNetworkFeeSheetViewModel, + localizationManager: LocalizationManagerProtocol + ) { + self.presenter = presenter + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + self.localizationManager = localizationManager + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = SwapNetworkFeeSheetLayout() + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupHandlers() + setupLocalization() + setupSwitch() + } + + private func setupLocalization() { + rootView.titleLabel.text = viewModel.title.value(for: selectedLocale) + rootView.detailsLabel.text = viewModel.message.value(for: selectedLocale) + rootView.hint.detailsLabel.text = viewModel.hint.value(for: selectedLocale) + rootView.feeTypeSwitch.titles = (0 ..< viewModel.count).map { index in + viewModel.sectionTitle(index).value(for: selectedLocale) + } + } + + private func setupSwitch() { + rootView.feeTypeSwitch.selectedSegmentIndex = viewModel.selectedIndex + } + + private func setupHandlers() { + rootView.feeTypeSwitch.addTarget(self, action: #selector(switchAction), for: .valueChanged) + } + + @objc private func switchAction() { + viewModel.action(rootView.feeTypeSwitch.selectedSegmentIndex) + } +} + +extension SwapNetworkFeeSheetViewController: MessageSheetViewProtocol {} + +extension SwapNetworkFeeSheetViewController: ModalPresenterDelegate { + func presenterShouldHide(_: ModalPresenterProtocol) -> Bool { + true + } +} + +extension SwapNetworkFeeSheetViewController: Localizable { + func applyLocalization() { + if isViewLoaded { + setupLocalization() + } + } +} diff --git a/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetViewFactory.swift b/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetViewFactory.swift new file mode 100644 index 0000000000..11780e5d84 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetViewFactory.swift @@ -0,0 +1,29 @@ +import Foundation +import SoraFoundation + +struct SwapNetworkFeeSheetViewFactory { + static func createView(from viewModel: SwapNetworkFeeSheetViewModel) -> MessageSheetViewProtocol { + let wireframe = MessageSheetWireframe() + + let presenter = MessageSheetPresenter(wireframe: wireframe) + + let view = SwapNetworkFeeSheetViewController( + presenter: presenter, + viewModel: viewModel, + localizationManager: LocalizationManager.shared + ) + + let height = view.rootView.contentHeight( + model: viewModel, + locale: LocalizationManager.shared.selectedLocale + ) + + view.preferredContentSize = .init( + width: UIView.noIntrinsicMetric, + height: height + ) + presenter.view = view + + return view + } +} diff --git a/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetViewModel.swift b/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetViewModel.swift new file mode 100644 index 0000000000..f1072fe1fa --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/NetworkFeeBottomSheet/SwapNetworkFeeSheetViewModel.swift @@ -0,0 +1,11 @@ +import SoraFoundation + +struct SwapNetworkFeeSheetViewModel { + let title: LocalizableResource + let message: LocalizableResource + let sectionTitle: (Int) -> LocalizableResource + let action: (Int) -> Void + let selectedIndex: Int + let count: Int + let hint: LocalizableResource +} diff --git a/novawallet/Modules/Swaps/Setup/SwapSetupInteractor.swift b/novawallet/Modules/Swaps/Setup/SwapSetupInteractor.swift new file mode 100644 index 0000000000..53f57fe2b5 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/SwapSetupInteractor.swift @@ -0,0 +1,241 @@ +import UIKit +import RobinHood +import BigInt +import SubstrateSdk + +final class SwapSetupInteractor: SwapBaseInteractor { + let storageRepository: AnyDataProviderRepository + + private var canPayFeeInAssetCall = CancellableCallStore() + + private var remoteSubscription: CallbackBatchStorageSubscription? + private var blockNumberSubscription: AnyDataProvider? + + init( + assetConversionAggregatorFactory: AssetConversionAggregationFactoryProtocol, + assetConversionFeeService: AssetConversionFeeServiceProtocol, + chainRegistry: ChainRegistryProtocol, + assetStorageFactory: AssetStorageInfoOperationFactoryProtocol, + priceLocalSubscriptionFactory: PriceProviderFactoryProtocol, + walletLocalSubscriptionFactory: WalletLocalSubscriptionFactoryProtocol, + generalLocalSubscriptionFactory: GeneralStorageSubscriptionFactoryProtocol, + storageRepository: AnyDataProviderRepository, + currencyManager: CurrencyManagerProtocol, + selectedWallet: MetaAccountModel, + operationQueue: OperationQueue + ) { + self.storageRepository = storageRepository + + super.init( + assetConversionAggregator: assetConversionAggregatorFactory, + assetConversionFeeService: assetConversionFeeService, + chainRegistry: chainRegistry, + assetStorageFactory: assetStorageFactory, + priceLocalSubscriptionFactory: priceLocalSubscriptionFactory, + walletLocalSubscriptionFactory: walletLocalSubscriptionFactory, + generalSubscriptionFactory: generalLocalSubscriptionFactory, + currencyManager: currencyManager, + selectedWallet: selectedWallet, + operationQueue: operationQueue + ) + } + + weak var presenter: SwapSetupInteractorOutputProtocol? { + basePresenter as? SwapSetupInteractorOutputProtocol + } + + private var receiveChainAsset: ChainAsset? { + didSet { + updateSubscriptions(activeChainAssets: activeChainAssets) + } + } + + private var payChainAsset: ChainAsset? { + didSet { + updateSubscriptions(activeChainAssets: activeChainAssets) + } + } + + private var feeChainAsset: ChainAsset? { + didSet { + updateSubscriptions(activeChainAssets: activeChainAssets) + } + } + + private var activeChainAssets: Set { + Set( + [ + receiveChainAsset?.chainAssetId, + payChainAsset?.chainAssetId, + feeChainAsset?.chainAssetId, + feeChainAsset?.chain.utilityChainAssetId() + ].compactMap { $0 } + ) + } + + deinit { + canPayFeeInAssetCall.cancel() + clearRemoteSubscription() + } + + private func provideCanPayFee(for asset: ChainAsset) { + canPayFeeInAssetCall.cancel() + + guard let utilityAssetId = asset.chain.utilityChainAssetId() else { + presenter?.didReceiveCanPayFeeInPayAsset(false, chainAssetId: asset.chainAssetId) + return + } + + let wrapper = assetConversionAggregator.createAvailableDirectionsWrapper(for: asset) + + executeCancellable( + wrapper: wrapper, + inOperationQueue: operationQueue, + backingCallIn: canPayFeeInAssetCall, + runningCallbackIn: .main + ) { [weak self] result in + switch result { + case let .success(chainAssetIds): + let canPayFee = chainAssetIds.contains(utilityAssetId) + self?.presenter?.didReceiveCanPayFeeInPayAsset(canPayFee, chainAssetId: asset.chainAssetId) + case let .failure(error): + self?.presenter?.didReceive(setupError: .payAssetSetFailed(error)) + } + } + } + + private func clearRemoteSubscription() { + remoteSubscription?.unsubscribe() + remoteSubscription = nil + } + + private func setupRemoteSubscription(for chain: ChainModel) throws { + guard + let accountId = selectedWallet.fetch(for: chain.accountRequest())?.accountId, + let connection = chainRegistry.getConnection(for: chain.chainId), + let runtimeService = chainRegistry.getRuntimeProvider(for: chain.chainId) else { + return + } + + let localKeyFactory = LocalStorageKeyFactory() + + let blockNumberKey = try localKeyFactory.createFromStoragePath(.blockNumber, chainId: chain.chainId) + let blockNumberRequest = BatchStorageSubscriptionRequest( + innerRequest: UnkeyedSubscriptionRequest( + storagePath: .blockNumber, + localKey: blockNumberKey + ), + mappingKey: nil + ) + + let accountInfoKey = try localKeyFactory.createFromStoragePath( + .account, + accountId: accountId, + chainId: chain.chainId + ) + let accountInfoRequest = BatchStorageSubscriptionRequest( + innerRequest: MapSubscriptionRequest( + storagePath: .account, + localKey: accountInfoKey, + keyParamClosure: { + BytesCodable(wrappedValue: accountId) + } + ), + mappingKey: nil + ) + + remoteSubscription = CallbackBatchStorageSubscription( + requests: [blockNumberRequest, accountInfoRequest], + connection: connection, + runtimeService: runtimeService, + repository: storageRepository, + operationQueue: operationQueue, + callbackQueue: .main, + callbackClosure: { _ in + // we are listening remote subscription via database + } + ) + + remoteSubscription?.subscribe() + } + + private func updateBlockNumberSubscription(for chain: ChainModel) { + clear(dataProvider: &blockNumberSubscription) + blockNumberSubscription = subscribeToBlockNumber(for: chain.chainId) + } + + override func updateChain(with newChain: ChainModel) { + let oldChainId = currentChain?.chainId + + super.updateChain(with: newChain) + + if newChain.chainId != oldChainId { + updateBlockNumberSubscription(for: newChain) + + do { + clearRemoteSubscription() + try setupRemoteSubscription(for: newChain) + } catch { + presenter?.didReceive(setupError: .remoteSubscription(error)) + } + } + } + + override func handleBlockNumber( + result: Result, + chainId: ChainModel.Id + ) { + switch result { + case let .success(blockNumber): + presenter?.didReceiveBlockNumber(blockNumber, chainId: chainId) + case let .failure(error): + presenter?.didReceive(setupError: .blockNumber(error)) + } + } +} + +extension SwapSetupInteractor: SwapSetupInteractorInputProtocol { + func update(receiveChainAsset: ChainAsset?) { + self.receiveChainAsset = receiveChainAsset + receiveChainAsset.map { + set(receiveChainAsset: $0) + } + } + + func update(payChainAsset: ChainAsset?) { + self.payChainAsset = payChainAsset + + if let payChainAsset = payChainAsset { + set(payChainAsset: payChainAsset) + provideCanPayFee(for: payChainAsset) + } + } + + func update(feeChainAsset: ChainAsset?) { + self.feeChainAsset = feeChainAsset + feeChainAsset.map { + set(feeChainAsset: $0) + } + } + + func retryRemoteSubscription() { + guard let chain = currentChain else { + return + } + + do { + clearRemoteSubscription() + try setupRemoteSubscription(for: chain) + } catch { + presenter?.didReceive(setupError: .remoteSubscription(error)) + } + } + + func retryBlockNumberSubscription() { + guard let chain = currentChain else { + return + } + + updateBlockNumberSubscription(for: chain) + } +} diff --git a/novawallet/Modules/Swaps/Setup/SwapSetupPresenter.swift b/novawallet/Modules/Swaps/Setup/SwapSetupPresenter.swift new file mode 100644 index 0000000000..25bed7a2bf --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/SwapSetupPresenter.swift @@ -0,0 +1,951 @@ +import Foundation +import SoraFoundation +import BigInt + +final class SwapSetupPresenter: SwapBasePresenter { + weak var view: SwapSetupViewProtocol? + let wireframe: SwapSetupWireframeProtocol + let interactor: SwapSetupInteractorInputProtocol + let initState: SwapSetupInitState + + private(set) var viewModelFactory: SwapsSetupViewModelFactoryProtocol + + private(set) var quoteArgs: AssetConversion.QuoteArgs? { + didSet { + provideDetailsViewModel() + } + } + + private var payAmountInput: AmountInputResult? + private var receiveAmountInput: Decimal? + + private var canPayFeeInPayAsset: Bool = false + private var payChainAsset: ChainAsset? + private var receiveChainAsset: ChainAsset? + private var feeChainAsset: ChainAsset? + + private var feeIdentifier: SwapSetupFeeIdentifier? + private var slippage: BigRational + private var isManualFeeSet: Bool = false + + private var detailsAvailable: Bool { + !quoteResult.hasError() && quoteArgs != nil + } + + init( + initState: SwapSetupInitState, + interactor: SwapSetupInteractorInputProtocol, + wireframe: SwapSetupWireframeProtocol, + viewModelFactory: SwapsSetupViewModelFactoryProtocol, + dataValidatingFactory: SwapDataValidatorFactoryProtocol, + localizationManager: LocalizationManagerProtocol, + selectedWallet: MetaAccountModel, + slippageConfig: SlippageConfig, + logger: LoggerProtocol + ) { + self.initState = initState + payChainAsset = initState.payChainAsset + feeChainAsset = initState.feeChainAsset ?? payChainAsset?.chain.utilityChainAsset() + receiveChainAsset = initState.receiveChainAsset + + self.interactor = interactor + self.wireframe = wireframe + self.viewModelFactory = viewModelFactory + slippage = slippageConfig.defaultSlippage + + super.init( + selectedWallet: selectedWallet, + dataValidatingFactory: dataValidatingFactory, + logger: logger + ) + + self.localizationManager = localizationManager + } + + // MARK: Base implementation + + override func getSpendingInputAmount() -> Decimal? { + guard let payAmountInput = payAmountInput else { + return nil + } + + let maxAmount = getMaxModel()?.calculate() ?? 0 + return payAmountInput.absoluteValue(from: maxAmount) + } + + override func getPayChainAsset() -> ChainAsset? { + payChainAsset + } + + override func getReceiveChainAsset() -> ChainAsset? { + receiveChainAsset + } + + override func getFeeChainAsset() -> ChainAsset? { + feeChainAsset + } + + override func getQuoteArgs() -> AssetConversion.QuoteArgs? { + quoteArgs + } + + override func getSlippage() -> BigRational { + slippage + } + + override func shouldHandleQuote(for args: AssetConversion.QuoteArgs?) -> Bool { + quoteArgs == args + } + + override func shouldHandleFee(for feeIdentifier: TransactionFeeId, feeChainAssetId: ChainAssetId?) -> Bool { + self.feeIdentifier == SwapSetupFeeIdentifier(transactionId: feeIdentifier, feeChainAssetId: feeChainAssetId) + } + + override func estimateFee() { + guard let quote = quote, + let receiveChain = receiveChainAsset?.chain, + let accountId = selectedWallet.fetch(for: receiveChain.accountRequest())?.accountId, + let quoteArgs = quoteArgs else { + return + } + + let args = AssetConversion.CallArgs( + assetIn: quote.assetIn, + amountIn: quote.amountIn, + assetOut: quote.assetOut, + amountOut: quote.amountOut, + receiver: accountId, + direction: quoteArgs.direction, + slippage: slippage + ) + + let newIdentifier = SwapSetupFeeIdentifier( + transactionId: args.identifier, + feeChainAssetId: feeChainAsset?.chainAssetId + ) + + guard newIdentifier != feeIdentifier || fee == nil else { + return + } + + fee = nil + provideFeeViewModel() + provideNotification() + + feeIdentifier = newIdentifier + interactor.calculateFee(args: args) + } + + override func applySwapMax() { + payAmountInput = .rate(1) + providePayAssetViews() + refreshQuote(direction: .sell) + provideButtonState() + provideIssues() + } + + override func handleBaseError(_ error: SwapBaseError) { + handleBaseError( + error, + view: view, + interactor: interactor, + wireframe: wireframe, + locale: selectedLocale + ) + + provideIssues() + provideDetailsViewModel() + } + + override func handleNewQuote(_ quote: AssetConversion.Quote, for quoteArgs: AssetConversion.QuoteArgs) { + logger.debug("New quote: \(quote)") + + switch quoteArgs.direction { + case .buy: + let payAmount = payChainAsset.map { + Decimal.fromSubstrateAmount( + quote.amountIn, + precision: Int16($0.asset.precision) + ) ?? 0 + } + payAmountInput = payAmount.map { .absolute($0) } + providePayAmountInputViewModel() + providePayInputPriceViewModel() + provideReceiveInputPriceViewModel() + case .sell: + receiveAmountInput = receiveChainAsset.map { + Decimal.fromSubstrateAmount( + quote.amountOut, + precision: $0.asset.displayInfo.assetPrecision + ) ?? 0 + } + provideReceiveAmountInputViewModel() + provideReceiveInputPriceViewModel() + providePayInputPriceViewModel() + } + + provideRateViewModel() + provideButtonState() + provideDetailsViewModel() + estimateFee() + } + + override func handleNewFee( + _: AssetConversion.FeeModel?, + transactionFeeId _: TransactionFeeId, + feeChainAssetId _: ChainAssetId? + ) { + provideFeeViewModel() + + if case .rate = payAmountInput { + providePayAmountInputViewModel() + providePayInputPriceViewModel() + + // as fee changes the max amount we might also refresh the quote + refreshQuote(direction: quoteArgs?.direction ?? .sell, forceUpdate: false) + } + + provideButtonState() + provideIssues() + provideNotification() + switchFeeChainAssetIfNecessary() + } + + override func handleNewPrice(_: PriceData?, chainAssetId: ChainAssetId) { + if payChainAsset?.chainAssetId == chainAssetId { + providePayInputPriceViewModel() + } + + if receiveChainAsset?.chainAssetId == chainAssetId { + provideReceiveInputPriceViewModel() + } + + if feeChainAsset?.chainAssetId == chainAssetId { + provideFeeViewModel() + } + + provideNotification() + } + + override func handleNewBalance(_: AssetBalance?, for chainAsset: ChainAssetId) { + if payChainAsset?.chainAssetId == chainAsset { + providePayTitle() + + if case .rate = payAmountInput { + providePayInputPriceViewModel() + providePayAmountInputViewModel() + provideButtonState() + } + } + + provideIssues() + switchFeeChainAssetIfNecessary() + } + + override func handleNewBalanceExistense(_: AssetBalanceExistence, chainAssetId _: ChainAssetId) { + if case .rate = payAmountInput { + providePayInputPriceViewModel() + providePayAmountInputViewModel() + provideButtonState() + } + + provideIssues() + } + + override func handleNewAccountInfo(_: AccountInfo?, chainId _: ChainModel.Id) { + if case .rate = payAmountInput { + providePayInputPriceViewModel() + providePayAmountInputViewModel() + provideButtonState() + } + } +} + +extension SwapSetupPresenter { + private func getPayAmount(for input: AmountInputResult?) -> Decimal? { + guard let input = input else { + return nil + } + + let maxAmount = getMaxModel()?.calculate() + return input.absoluteValue(from: maxAmount ?? 0) + } + + func getIssueParams() -> SwapIssueCheckParams { + .init( + payChainAsset: payChainAsset, + receiveChainAsset: receiveChainAsset, + payAmount: getSpendingInputAmount(), + receiveAmount: receiveAmountInput, + payAssetBalance: payAssetBalance, + receiveAssetBalance: receiveAssetBalance, + payAssetExistense: payAssetBalanceExistense, + receiveAssetExistense: receiveAssetBalanceExistense, + quoteResult: quoteResult + ) + } + + private func providePayTitle() { + let payTitleViewModel = viewModelFactory.payTitleViewModel( + assetDisplayInfo: payChainAsset?.assetDisplayInfo, + maxValue: payAssetBalance?.transferable, + locale: selectedLocale + ) + view?.didReceiveTitle(payViewModel: payTitleViewModel) + } + + private func providePayAssetViewModel() { + let payAssetViewModel = viewModelFactory.payAssetViewModel( + chainAsset: payChainAsset, + locale: selectedLocale + ) + + view?.didReceiveInputChainAsset(payViewModel: payAssetViewModel) + } + + private func providePayAmountInputViewModel() { + guard let payChainAsset = payChainAsset else { + return + } + + let amountInputViewModel = viewModelFactory.amountInputViewModel( + chainAsset: payChainAsset, + amount: getPayAmount(for: payAmountInput), + locale: selectedLocale + ) + + view?.didReceiveAmount(payInputViewModel: amountInputViewModel) + } + + private func providePayInputPriceViewModel() { + guard let assetDisplayInfo = payChainAsset?.assetDisplayInfo else { + view?.didReceiveAmountInputPrice(payViewModel: nil) + return + } + + let inputPriceViewModel = viewModelFactory.inputPriceViewModel( + assetDisplayInfo: assetDisplayInfo, + amount: getPayAmount(for: payAmountInput), + priceData: payAssetPriceData, + locale: selectedLocale + ) + + view?.didReceiveAmountInputPrice(payViewModel: inputPriceViewModel) + } + + private func provideReceiveTitle() { + let receiveTitleViewModel = viewModelFactory.receiveTitleViewModel( + for: selectedLocale + ) + + view?.didReceiveTitle(receiveViewModel: receiveTitleViewModel) + } + + private func provideReceiveAssetViewModel() { + let receiveAssetViewModel = viewModelFactory.receiveAssetViewModel( + chainAsset: receiveChainAsset, + locale: selectedLocale + ) + + view?.didReceiveInputChainAsset(receiveViewModel: receiveAssetViewModel) + } + + private func provideReceiveAmountInputViewModel() { + guard let receiveChainAsset = receiveChainAsset else { + return + } + let amountInputViewModel = viewModelFactory.amountInputViewModel( + chainAsset: receiveChainAsset, + amount: receiveAmountInput, + locale: selectedLocale + ) + + view?.didReceiveAmount(receiveInputViewModel: amountInputViewModel) + } + + private func provideReceiveInputPriceViewModel() { + guard let assetDisplayInfo = receiveChainAsset?.assetDisplayInfo else { + view?.didReceiveAmountInputPrice(receiveViewModel: nil) + return + } + + let inputPriceViewModel = viewModelFactory.inputPriceViewModel( + assetDisplayInfo: assetDisplayInfo, + amount: receiveAmountInput, + priceData: receiveAssetPriceData, + locale: selectedLocale + ) + + let differenceViewModel: DifferenceViewModel? + if let quote = quote, let payAssetDisplayInfo = payChainAsset?.assetDisplayInfo { + let params = RateParams( + assetDisplayInfoIn: payAssetDisplayInfo, + assetDisplayInfoOut: assetDisplayInfo, + amountIn: quote.amountIn, + amountOut: quote.amountOut + ) + + differenceViewModel = viewModelFactory.priceDifferenceViewModel( + rateParams: params, + priceIn: payAssetPriceData, + priceOut: receiveAssetPriceData, + locale: selectedLocale + ) + } else { + differenceViewModel = nil + } + + view?.didReceiveAmountInputPrice(receiveViewModel: .init( + price: inputPriceViewModel, + difference: differenceViewModel + )) + } + + private func providePayAssetViews() { + providePayTitle() + providePayAssetViewModel() + providePayInputPriceViewModel() + providePayAmountInputViewModel() + } + + private func provideReceiveAssetViews() { + provideReceiveTitle() + provideReceiveAssetViewModel() + provideReceiveInputPriceViewModel() + provideReceiveAmountInputViewModel() + } + + private func provideButtonState() { + let buttonState = viewModelFactory.buttonState( + for: getIssueParams(), + locale: selectedLocale + ) + + view?.didReceiveButtonState( + title: buttonState.title.value(for: selectedLocale), + enabled: buttonState.enabled + ) + } + + private func provideSettingsState() { + view?.didReceiveSettingsState(isAvailable: payChainAsset != nil) + } + + private func provideDetailsViewModel() { + view?.didReceiveDetailsState(isAvailable: detailsAvailable) + } + + private func provideRateViewModel() { + guard + let assetDisplayInfoIn = payChainAsset?.assetDisplayInfo, + let assetDisplayInfoOut = receiveChainAsset?.assetDisplayInfo, + let quote = quote else { + view?.didReceiveRate(viewModel: .loading) + return + } + let rateViewModel = viewModelFactory.rateViewModel( + from: .init( + assetDisplayInfoIn: assetDisplayInfoIn, + assetDisplayInfoOut: assetDisplayInfoOut, + amountIn: quote.amountIn, + amountOut: quote.amountOut + ), + locale: selectedLocale + ) + + view?.didReceiveRate(viewModel: .loaded(value: rateViewModel)) + } + + private func provideFeeViewModel() { + guard quoteArgs != nil, let feeChainAsset = feeChainAsset else { + return + } + guard let fee = fee?.networkFee.targetAmount else { + view?.didReceiveNetworkFee(viewModel: .loading) + return + } + let isEditable = (payChainAsset?.isUtilityAsset == false) && canPayFeeInPayAsset + let viewModel = viewModelFactory.feeViewModel( + amount: fee, + assetDisplayInfo: feeChainAsset.assetDisplayInfo, + isEditable: isEditable, + priceData: feeAssetPriceData, + locale: selectedLocale + ) + + view?.didReceiveNetworkFee(viewModel: .loaded(value: viewModel)) + } + + private func provideIssues() { + let issues = viewModelFactory.detectIssues(in: getIssueParams(), locale: selectedLocale) + view?.didReceive(issues: issues) + } + + private func provideNotification() { + guard + let networkFeeAddition = fee?.networkNativeFeeAddition, + let feeChainAsset = feeChainAsset, + !feeChainAsset.isUtilityAsset, + let utilityChainAsset = feeChainAsset.chain.utilityChainAsset() else { + view?.didSetNotification(message: nil) + return + } + + let message = viewModelFactory.minimalBalanceSwapForFeeMessage( + for: networkFeeAddition, + feeChainAsset: feeChainAsset, + utilityChainAsset: utilityChainAsset, + utilityPriceData: prices[utilityChainAsset.chainAssetId], + locale: selectedLocale + ) + + view?.didSetNotification(message: message) + } + + func refreshQuote(direction: AssetConversion.Direction, forceUpdate: Bool = true) { + guard + let payChainAsset = payChainAsset, + let receiveChainAsset = receiveChainAsset else { + return + } + + if forceUpdate { + quoteResult = nil + } + + switch direction { + case .buy: + refreshQuoteForBuy( + payChainAsset: payChainAsset, + receiveChainAsset: receiveChainAsset, + forceUpdate: forceUpdate + ) + case .sell: + refreshQuoteForSell( + payChainAsset: payChainAsset, + receiveChainAsset: receiveChainAsset, + forceUpdate: forceUpdate + ) + } + + provideRateViewModel() + provideFeeViewModel() + } + + private func refreshQuoteForBuy(payChainAsset: ChainAsset, receiveChainAsset: ChainAsset, forceUpdate: Bool) { + if + let receiveInPlank = receiveAmountInput?.toSubstrateAmount( + precision: receiveChainAsset.assetDisplayInfo.assetPrecision + ), + receiveInPlank > 0 { + let quoteArgs = AssetConversion.QuoteArgs( + assetIn: payChainAsset.chainAssetId, + assetOut: receiveChainAsset.chainAssetId, + amount: receiveInPlank, + direction: .buy + ) + self.quoteArgs = quoteArgs + interactor.calculateQuote(for: quoteArgs) + } else { + quoteArgs = nil + + if forceUpdate { + payAmountInput = nil + providePayAmountInputViewModel() + provideIssues() + provideFeeViewModel() + provideNotification() + } else { + refreshQuote(direction: .sell) + } + } + } + + private func refreshQuoteForSell(payChainAsset: ChainAsset, receiveChainAsset: ChainAsset, forceUpdate: Bool) { + if let payInPlank = getPayAmount(for: payAmountInput)?.toSubstrateAmount( + precision: Int16(payChainAsset.assetDisplayInfo.assetPrecision)), payInPlank > 0 { + let quoteArgs = AssetConversion.QuoteArgs( + assetIn: payChainAsset.chainAssetId, + assetOut: receiveChainAsset.chainAssetId, + amount: payInPlank, + direction: .sell + ) + self.quoteArgs = quoteArgs + interactor.calculateQuote(for: quoteArgs) + } else { + quoteArgs = nil + + if forceUpdate { + receiveAmountInput = nil + provideReceiveAmountInputViewModel() + provideReceiveInputPriceViewModel() + provideIssues() + provideFeeViewModel() + provideNotification() + } else { + refreshQuote(direction: .buy) + } + } + } + + private func updateFeeChainAsset(_ chainAsset: ChainAsset?) { + feeChainAsset = chainAsset + providePayAssetViews() + interactor.update(feeChainAsset: chainAsset) + + fee = nil + provideFeeViewModel() + provideNotification() + + estimateFee() + } + + private func updateViews() { + providePayAssetViews() + provideReceiveAssetViews() + provideDetailsViewModel() + provideButtonState() + provideSettingsState() + provideIssues() + provideNotification() + } + + private func switchFeeChainAssetIfNecessary() { + guard + !isManualFeeSet, + let payChainAsset = getPayChainAsset(), + !payChainAsset.isUtilityAsset, + let feeChainAsset = getFeeChainAsset(), + feeChainAsset.isUtilityAsset, + let feeAssetBalance = feeAssetBalance, + let payAssetBalance = payAssetBalance, + payAssetBalance.transferable > 0, + let fee = fee?.totalFee.nativeAmount, + let nativeMinBalance = utilityAssetBalanceExistense?.minBalance else { + return + } + + if feeAssetBalance.freeInPlank < fee + nativeMinBalance { + updateFeeChainAsset(payChainAsset) + } + } +} + +extension SwapSetupPresenter: SwapSetupPresenterProtocol { + func setup() { + updateViews() + + interactor.setup() + interactor.update(payChainAsset: payChainAsset) + interactor.update(feeChainAsset: feeChainAsset) + + if let amount = initState.amount, let direction = initState.direction { + switch direction { + case .sell: + updatePayAmount(amount) + providePayAssetViews() + case .buy: + updateReceiveAmount(amount) + provideReceiveAssetViews() + } + } + } + + func selectPayToken() { + wireframe.showPayTokenSelection(from: view, chainAsset: receiveChainAsset) { [weak self] chainAsset in + self?.payChainAsset = chainAsset + let feeChainAsset = chainAsset.chain.utilityAsset().map { + ChainAsset(chain: chainAsset.chain, asset: $0) + } + + self?.feeChainAsset = feeChainAsset + self?.fee = nil + self?.canPayFeeInPayAsset = false + + self?.providePayAssetViews() + self?.provideButtonState() + self?.provideSettingsState() + self?.provideFeeViewModel() + self?.provideIssues() + + self?.interactor.update(payChainAsset: chainAsset) + self?.interactor.update(feeChainAsset: feeChainAsset) + self?.isManualFeeSet = false + + if let direction = self?.quoteArgs?.direction { + self?.refreshQuote(direction: direction, forceUpdate: false) + } else if self?.payAmountInput != nil { + self?.refreshQuote(direction: .sell, forceUpdate: false) + } else { + self?.refreshQuote(direction: .buy, forceUpdate: false) + } + } + } + + func selectReceiveToken() { + wireframe.showReceiveTokenSelection(from: view, chainAsset: payChainAsset) { [weak self] chainAsset in + self?.receiveChainAsset = chainAsset + self?.provideReceiveAssetViews() + self?.provideButtonState() + self?.provideIssues() + + self?.interactor.update(receiveChainAsset: chainAsset) + + if let direction = self?.quoteArgs?.direction { + self?.refreshQuote(direction: direction, forceUpdate: false) + } else if self?.receiveAmountInput != nil { + self?.refreshQuote(direction: .buy, forceUpdate: false) + } else { + self?.refreshQuote(direction: .sell, forceUpdate: false) + } + } + } + + func updatePayAmount(_ amount: Decimal?) { + payAmountInput = amount.map { .absolute($0) } + refreshQuote(direction: .sell) + providePayInputPriceViewModel() + provideReceiveInputPriceViewModel() + provideButtonState() + provideIssues() + provideNotification() + } + + func updateReceiveAmount(_ amount: Decimal?) { + receiveAmountInput = amount + refreshQuote(direction: .buy) + provideReceiveInputPriceViewModel() + providePayInputPriceViewModel() + provideButtonState() + provideIssues() + provideNotification() + } + + func flip(currentFocus: TextFieldFocus?) { + let payAmount = getPayAmount(for: payAmountInput) + let receiveAmount = receiveAmountInput.map { AmountInputResult.absolute($0) } + + Swift.swap(&payChainAsset, &receiveChainAsset) + feeChainAsset = payChainAsset?.chain.utilityChainAsset() + canPayFeeInPayAsset = false + + interactor.update(payChainAsset: payChainAsset) + interactor.update(receiveChainAsset: receiveChainAsset) + interactor.update(feeChainAsset: feeChainAsset) + let newFocus: TextFieldFocus? + + switch currentFocus { + case .payAsset: + newFocus = .receiveAsset + case .receiveAsset: + newFocus = .payAsset + case .none: + newFocus = nil + } + + let previousDirection = quoteArgs?.direction + + switch previousDirection { + case .sell: + receiveAmountInput = payAmount + payAmountInput = nil + refreshQuote(direction: .buy, forceUpdate: true) + case .buy: + payAmountInput = receiveAmount + receiveAmountInput = nil + refreshQuote(direction: .sell, forceUpdate: true) + case .none: + payAmountInput = nil + receiveAmountInput = nil + } + + providePayAssetViews() + provideReceiveAssetViews() + provideButtonState() + provideSettingsState() + provideFeeViewModel() + provideIssues() + + view?.didReceive(focus: newFocus) + } + + func selectMaxPayAmount() { + applySwapMax() + } + + func showFeeActions() { + guard let payChainAsset = payChainAsset, + let utilityAsset = payChainAsset.chain.utilityChainAsset() else { + return + } + let payAssetSelected = feeChainAsset?.chainAssetId == payChainAsset.chainAssetId + let viewModel = SwapNetworkFeeSheetViewModel( + title: FeeSelectionViewModel.title, + message: FeeSelectionViewModel.message, + sectionTitle: { section in + .init { _ in + FeeSelectionViewModel(rawValue: section) == .utilityAsset ? + utilityAsset.asset.symbol : payChainAsset.asset.symbol + } + }, + action: { [weak self] in + let chainAsset = FeeSelectionViewModel(rawValue: $0) == .utilityAsset ? utilityAsset : payChainAsset + if chainAsset.chainAssetId != self?.feeChainAsset?.chainAssetId { + self?.isManualFeeSet = true + } + self?.updateFeeChainAsset(chainAsset) + }, + selectedIndex: payAssetSelected ? FeeSelectionViewModel.payAsset.rawValue : + FeeSelectionViewModel.utilityAsset.rawValue, + count: FeeSelectionViewModel.allCases.count, + hint: FeeSelectionViewModel.hint + ) + + wireframe.showNetworkFeeAssetSelection( + form: view, + viewModel: viewModel + ) + } + + func showFeeInfo() { + wireframe.showFeeInfo(from: view) + } + + func showRateInfo() { + wireframe.showRateInfo(from: view) + } + + func proceed() { + guard let swapModel = getSwapModel() else { + return + } + + let validators = getBaseValidations(for: swapModel, interactor: interactor, locale: selectedLocale) + + DataValidationRunner(validators: validators).runValidation( + notifyingOnSuccess: { [weak self] in + self?.view?.didStopLoading() + + guard let slippage = self?.slippage, + let quote = self?.quote, + let quoteArgs = self?.quoteArgs else { + return + } + + let confirmInitState = SwapConfirmInitState( + chainAssetIn: swapModel.payChainAsset, + chainAssetOut: swapModel.receiveChainAsset, + feeChainAsset: swapModel.feeChainAsset, + slippage: slippage, + quote: quote, + quoteArgs: quoteArgs + ) + + self?.wireframe.showConfirmation( + from: self?.view, + initState: confirmInitState + ) + }, + notifyingOnStop: { [weak self] _ in + self?.view?.didStopLoading() + }, notifyingOnResume: { [weak self] _ in + self?.view?.didStartLoading() + } + ) + } + + func showSettings() { + guard let payChainAsset = payChainAsset else { + return + } + wireframe.showSettings( + from: view, + percent: slippage, + chainAsset: payChainAsset + ) { [weak self, payChainAsset] slippageValue in + guard payChainAsset.chainAssetId == self?.payChainAsset?.chainAssetId else { + return + } + self?.slippage = slippageValue + self?.estimateFee() + } + } + + func depositInsufficientToken() { + guard let payChainAsset = payChainAsset else { + return + } + + wireframe.showGetTokenOptions( + form: view, + purchaseHadler: self, + destinationChainAsset: payChainAsset, + locale: selectedLocale + ) + } +} + +extension SwapSetupPresenter: SwapSetupInteractorOutputProtocol { + func didReceive(setupError: SwapSetupError) { + logger.error("Did receive setup error: \(setupError)") + + switch setupError { + case .payAssetSetFailed: + wireframe.presentRequestStatus(on: view, locale: selectedLocale) { [weak self] in + if let payChainAsset = self?.payChainAsset { + self?.interactor.update(payChainAsset: payChainAsset) + } + } + case .blockNumber: + wireframe.presentRequestStatus(on: view, locale: selectedLocale) { [weak self] in + self?.interactor.retryBlockNumberSubscription() + } + case .remoteSubscription: + wireframe.presentRequestStatus(on: view, locale: selectedLocale) { [weak self] in + self?.interactor.retryRemoteSubscription() + } + } + } + + func didReceiveCanPayFeeInPayAsset(_ value: Bool, chainAssetId: ChainAssetId) { + if payChainAsset?.chainAssetId == chainAssetId { + canPayFeeInPayAsset = value + + provideFeeViewModel() + } + } + + func didReceiveBlockNumber(_ blockNumber: BlockNumber?, chainId _: ChainModel.Id) { + logger.debug("New block number: \(String(describing: blockNumber))") + + refreshQuote(direction: quoteArgs?.direction ?? .sell, forceUpdate: false) + estimateFee() + } +} + +extension SwapSetupPresenter: Localizable { + func applyLocalization() { + if view?.isSetup == true { + updateViews() + } + } +} + +extension SwapSetupPresenter: PurchaseFlowManaging, PurchaseDelegate, ModalPickerViewControllerDelegate { + func modalPickerDidSelectModelAtIndex(_ index: Int, context: AnyObject?) { + guard let actions = context as? [PurchaseAction] else { + return + } + + startPuchaseFlow( + from: view, + purchaseAction: actions[index], + wireframe: wireframe, + locale: selectedLocale + ) + } + + func purchaseDidComplete() { + wireframe.presentPurchaseDidComplete(view: view, locale: selectedLocale) + } +} diff --git a/novawallet/Modules/Swaps/Setup/SwapSetupProtocols.swift b/novawallet/Modules/Swaps/Setup/SwapSetupProtocols.swift new file mode 100644 index 0000000000..b15568591d --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/SwapSetupProtocols.swift @@ -0,0 +1,107 @@ +import BigInt +import SoraFoundation + +protocol SwapSetupViewProtocol: ControllerBackedProtocol { + func didReceiveButtonState(title: String, enabled: Bool) + func didReceiveInputChainAsset(payViewModel viewModel: SwapAssetInputViewModel) + func didReceiveAmount(payInputViewModel inputViewModel: AmountInputViewModelProtocol) + func didReceiveAmountInputPrice(payViewModel: String?) + func didReceiveTitle(payViewModel viewModel: TitleHorizontalMultiValueView.Model) + func didReceiveInputChainAsset(receiveViewModel viewModel: SwapAssetInputViewModel) + func didReceiveAmount(receiveInputViewModel inputViewModel: AmountInputViewModelProtocol) + func didReceiveAmountInputPrice(receiveViewModel: SwapPriceDifferenceViewModel?) + func didReceiveTitle(receiveViewModel viewModel: TitleHorizontalMultiValueView.Model) + func didReceiveRate(viewModel: LoadableViewModelState) + func didReceiveNetworkFee(viewModel: LoadableViewModelState) + func didReceiveDetailsState(isAvailable: Bool) + func didReceiveSettingsState(isAvailable: Bool) + func didReceive(issues: [SwapSetupViewIssue]) + func didSetNotification(message: String?) + func didReceive(focus: TextFieldFocus?) + func didStartLoading() + func didStopLoading() +} + +protocol SwapSetupPresenterProtocol: AnyObject { + func setup() + func selectPayToken() + func selectReceiveToken() + func proceed() + func flip(currentFocus: TextFieldFocus?) + func updatePayAmount(_ amount: Decimal?) + func updateReceiveAmount(_ amount: Decimal?) + func showFeeActions() + func showFeeInfo() + func showRateInfo() + func showSettings() + func selectMaxPayAmount() + func depositInsufficientToken() +} + +protocol SwapSetupInteractorInputProtocol: SwapBaseInteractorInputProtocol { + func setup() + func update(receiveChainAsset: ChainAsset?) + func update(payChainAsset: ChainAsset?) + func update(feeChainAsset: ChainAsset?) + func retryRemoteSubscription() + func retryBlockNumberSubscription() +} + +protocol SwapSetupInteractorOutputProtocol: SwapBaseInteractorOutputProtocol { + func didReceiveCanPayFeeInPayAsset(_ value: Bool, chainAssetId: ChainAssetId) + func didReceiveBlockNumber(_ blockNumber: BlockNumber?, chainId: ChainModel.Id) + func didReceive(setupError: SwapSetupError) +} + +protocol SwapSetupWireframeProtocol: SwapBaseWireframeProtocol, ShortTextInfoPresentable, PurchasePresentable { + func showPayTokenSelection( + from view: ControllerBackedProtocol?, + chainAsset: ChainAsset?, + completionHandler: @escaping (ChainAsset) -> Void + ) + func showReceiveTokenSelection( + from view: ControllerBackedProtocol?, + chainAsset: ChainAsset?, + completionHandler: @escaping (ChainAsset) -> Void + ) + func showSettings( + from view: ControllerBackedProtocol?, + percent: BigRational?, + chainAsset: ChainAsset, + completionHandler: @escaping (BigRational) -> Void + ) + func showInfo( + from view: ControllerBackedProtocol?, + title: LocalizableResource, + details: LocalizableResource + ) + func showConfirmation( + from view: ControllerBackedProtocol?, + initState: SwapConfirmInitState + ) + func showNetworkFeeAssetSelection( + form view: ControllerBackedProtocol?, + viewModel: SwapNetworkFeeSheetViewModel + ) + + func showGetTokenOptions( + form view: ControllerBackedProtocol?, + purchaseHadler: PurchaseFlowManaging, + destinationChainAsset: ChainAsset, + locale: Locale + ) +} + +enum SwapSetupError: Error { + case payAssetSetFailed(Error) + case remoteSubscription(Error) + case blockNumber(Error) +} + +enum SwapSetupViewIssue: Equatable { + case zeroBalance + case insufficientBalance + case minBalanceViolation(String) + case noLiqudity + case zeroReceiveAmount +} diff --git a/novawallet/Modules/Swaps/Setup/SwapSetupViewController.swift b/novawallet/Modules/Swaps/Setup/SwapSetupViewController.swift new file mode 100644 index 0000000000..def687e843 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/SwapSetupViewController.swift @@ -0,0 +1,350 @@ +import UIKit +import SoraFoundation + +final class SwapSetupViewController: UIViewController, ViewHolder { + typealias RootViewType = SwapSetupViewLayout + + let presenter: SwapSetupPresenterProtocol + + private var toggledDetailsManually: Bool = false + + init( + presenter: SwapSetupPresenterProtocol, + localizationManager: LocalizationManager + ) { + self.presenter = presenter + super.init(nibName: nil, bundle: nil) + self.localizationManager = localizationManager + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = SwapSetupViewLayout() + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupHandlers() + setupLocalization() + setupNavigationItem() + presenter.setup() + } + + private func setupHandlers() { + rootView.payAmountInputView.assetControl.addTarget( + self, + action: #selector(selectPayTokenAction), + for: .touchUpInside + ) + rootView.payAmountView.button.addTarget( + self, + action: #selector(payMaxAction), + for: .touchUpInside + ) + rootView.receiveAmountInputView.assetControl.addTarget( + self, + action: #selector(selectReceiveTokenAction), + for: .touchUpInside + ) + rootView.actionButton.addTarget( + self, + action: #selector(continueAction), + for: .touchUpInside + ) + rootView.switchButton.addTarget( + self, + action: #selector(swapAction), + for: .touchUpInside + ) + rootView.payAmountInputView.textInputView.addTarget( + self, + action: #selector(payAmountChangeAction), + for: .editingChanged + ) + rootView.receiveAmountInputView.textInputView.addTarget( + self, + action: #selector(receiveAmountChangeAction), + for: .editingChanged + ) + rootView.rateCell.addTarget( + self, + action: #selector(rateInfoAction), + for: .touchUpInside + ) + rootView.networkFeeCell.valueTopButton.addTarget( + self, + action: #selector(changeNetworkFeeAction), + for: .touchUpInside + ) + rootView.networkFeeCell.addTarget( + self, + action: #selector(networkFeeInfoAction), + for: .touchUpInside + ) + rootView.depositTokenButton.addTarget( + self, + action: #selector(depositTokenAction), + for: .touchUpInside + ) + + rootView.detailsView.delegate = self + } + + private func setupLocalization() { + title = R.string.localizable.commonSwapTitle(preferredLanguages: selectedLocale.rLanguages) + rootView.setup(locale: selectedLocale) + setupAccessoryView() + } + + private func setupAccessoryView() { + let accessoryView = + UIFactory.default.createDoneAccessoryView( + target: self, + selector: #selector(doneAction), + locale: selectedLocale + ) + rootView.payAmountInputView.textInputView.textField.inputAccessoryView = accessoryView + rootView.receiveAmountInputView.textInputView.textField.inputAccessoryView = accessoryView + } + + private func setupNavigationItem() { + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: R.image.iconOptions(), + style: .plain, + target: self, + action: #selector(settingsAction) + ) + } + + @objc private func selectPayTokenAction() { + rootView.receiveAmountInputView.endEditing(true) + presenter.selectPayToken() + } + + @objc private func selectReceiveTokenAction() { + rootView.payAmountInputView.endEditing(true) + presenter.selectReceiveToken() + } + + @objc private func continueAction() { + presenter.proceed() + } + + @objc private func swapAction() { + let currentFocus: TextFieldFocus? + if rootView.payAmountInputView.textInputView.textField.isFirstResponder { + currentFocus = .payAsset + } else if rootView.receiveAmountInputView.textInputView.textField.isFirstResponder { + currentFocus = .receiveAsset + } else { + currentFocus = nil + } + view.endEditing(true) + presenter.flip(currentFocus: currentFocus) + } + + @objc private func payAmountChangeAction() { + let amount = rootView.payAmountInputView.textInputView.inputViewModel?.decimalAmount + presenter.updatePayAmount(amount) + } + + @objc private func receiveAmountChangeAction() { + let amount = rootView.receiveAmountInputView.textInputView.inputViewModel?.decimalAmount + presenter.updateReceiveAmount(amount) + } + + @objc private func changeNetworkFeeAction() { + presenter.showFeeActions() + } + + @objc private func networkFeeInfoAction() { + presenter.showFeeInfo() + } + + @objc private func rateInfoAction() { + presenter.showRateInfo() + } + + @objc private func payMaxAction() { + presenter.selectMaxPayAmount() + } + + @objc private func doneAction() { + view.endEditing(true) + } + + @objc private func settingsAction() { + presenter.showSettings() + } + + @objc private func depositTokenAction() { + presenter.depositInsufficientToken() + } +} + +extension SwapSetupViewController: SwapSetupViewProtocol { + func didReceiveButtonState(title: String, enabled: Bool) { + rootView.actionButton.applyState(title: title, enabled: enabled) + } + + func didReceiveTitle(payViewModel viewModel: TitleHorizontalMultiValueView.Model) { + rootView.payAmountView.bind(model: viewModel) + } + + func didReceiveInputChainAsset(payViewModel viewModel: SwapAssetInputViewModel) { + switch viewModel { + case let .asset(assetViewModel): + rootView.payAmountInputView.bind(assetViewModel: assetViewModel) + rootView.depositTokenButton.imageWithTitleView?.title = R.string.localizable.swapsSetupDepositButtonTitle( + assetViewModel.symbol, + preferredLanguages: selectedLocale.rLanguages + ) + case let .empty(emptySwapsAssetViewModel): + rootView.payAmountInputView.bind(emptyViewModel: emptySwapsAssetViewModel) + rootView.depositTokenButton.imageWithTitleView?.title = nil + } + } + + func didReceiveAmount(payInputViewModel inputViewModel: AmountInputViewModelProtocol) { + rootView.payAmountInputView.bind(inputViewModel: inputViewModel) + } + + func didReceiveAmountInputPrice(payViewModel viewModel: String?) { + rootView.payAmountInputView.bind(priceViewModel: viewModel) + } + + func didReceiveTitle(receiveViewModel viewModel: TitleHorizontalMultiValueView.Model) { + rootView.receiveAmountView.bind(model: viewModel) + } + + func didReceiveInputChainAsset(receiveViewModel viewModel: SwapAssetInputViewModel) { + switch viewModel { + case let .asset(swapsAssetViewModel): + rootView.receiveAmountInputView.bind(assetViewModel: swapsAssetViewModel) + case let .empty(emptySwapsAssetViewModel): + rootView.receiveAmountInputView.bind(emptyViewModel: emptySwapsAssetViewModel) + } + } + + func didReceiveAmount(receiveInputViewModel inputViewModel: AmountInputViewModelProtocol) { + rootView.receiveAmountInputView.bind(inputViewModel: inputViewModel) + } + + func didReceiveAmountInputPrice(receiveViewModel viewModel: SwapPriceDifferenceViewModel?) { + rootView.receiveAmountInputView.bind(priceDifferenceViewModel: viewModel) + } + + func didReceiveDetailsState(isAvailable: Bool) { + rootView.detailsView.isHidden = !isAvailable + + if !isAvailable { + toggledDetailsManually = false + } + } + + func didReceiveRate(viewModel: LoadableViewModelState) { + rootView.rateCell.bind(loadableViewModel: viewModel) + } + + func didReceiveNetworkFee(viewModel: LoadableViewModelState) { + rootView.networkFeeCell.bind(loadableViewModel: viewModel) + + if !toggledDetailsManually, !rootView.detailsView.expanded { + rootView.detailsView.setExpanded(true, animated: true) + } + } + + func didReceiveSettingsState(isAvailable: Bool) { + navigationItem.rightBarButtonItem?.isEnabled = isAvailable + } + + func didReceive(focus: TextFieldFocus?) { + switch focus { + case .none: + rootView.payAmountInputView.set(focused: false) + rootView.receiveAmountInputView.set(focused: false) + case .payAsset: + rootView.payAmountInputView.set(focused: true) + case .receiveAsset: + rootView.receiveAmountInputView.set(focused: true) + } + } + + func didReceive(issues: [SwapSetupViewIssue]) { + rootView.hideIssues() + rootView.changeDepositTokenButtonVisibility(hidden: true) + + issues.forEach { issue in + switch issue { + case .zeroBalance: + rootView.changeDepositTokenButtonVisibility(hidden: false) + case .zeroReceiveAmount: + let message = R.string.localizable.commonPositiveAmount( + preferredLanguages: selectedLocale.rLanguages + ) + + rootView.displayReceiveIssue(with: message) + case .insufficientBalance: + rootView.changeDepositTokenButtonVisibility(hidden: false) + + let message = R.string.localizable.swapsNotEnoughTokens( + preferredLanguages: selectedLocale.rLanguages + ) + + rootView.displayPayIssue(with: message) + case let .minBalanceViolation(minBalance): + let message = R.string.localizable.commonReceiveAtLeastEdError( + minBalance, + preferredLanguages: selectedLocale.rLanguages + ) + + rootView.displayReceiveIssue(with: message) + case .noLiqudity: + let message = R.string.localizable.swapsNotEnoughLiquidity( + preferredLanguages: selectedLocale.rLanguages + ) + + rootView.displayPayIssue(with: message) + } + } + } + + func didSetNotification(message: String?) { + if let message = message { + rootView.displayInfoNotification(with: message) + } else { + rootView.hideNotification() + } + } + + func didStartLoading() { + rootView.loadableActionView.startLoading() + } + + func didStopLoading() { + rootView.loadableActionView.stopLoading() + } +} + +extension SwapSetupViewController: CollapsableContainerViewDelegate { + func animateAlongsideWithInfo(sender _: AnyObject?) { + rootView.containerView.scrollView.layoutIfNeeded() + } + + func didChangeExpansion(isExpanded _: Bool, sender _: AnyObject) { + toggledDetailsManually = true + } +} + +extension SwapSetupViewController: Localizable { + func applyLocalization() { + if isViewLoaded { + setupLocalization() + } + } +} diff --git a/novawallet/Modules/Swaps/Setup/SwapSetupViewFactory.swift b/novawallet/Modules/Swaps/Setup/SwapSetupViewFactory.swift new file mode 100644 index 0000000000..144b229c91 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/SwapSetupViewFactory.swift @@ -0,0 +1,133 @@ +import Foundation +import SoraFoundation +import RobinHood + +struct SwapSetupViewFactory { + static func createView( + assetListObservable: AssetListModelObservable, + payChainAsset: ChainAsset, + swapCompletionClosure: SwapCompletionClosure? + ) -> SwapSetupViewProtocol? { + createView( + assetListObservable: assetListObservable, + initState: .init(payChainAsset: payChainAsset), + swapCompletionClosure: swapCompletionClosure + ) + } + + static func createView( + assetListObservable: AssetListModelObservable, + initState: SwapSetupInitState, + swapCompletionClosure: SwapCompletionClosure? + ) -> SwapSetupViewProtocol? { + guard + let currencyManager = CurrencyManager.shared, + let selectedWallet = SelectedWalletSettings.shared.value else { + return nil + } + + let balanceViewModelFactoryFacade = BalanceViewModelFactoryFacade( + priceAssetInfoFactory: PriceAssetInfoFactory(currencyManager: currencyManager)) + + let generalLocalSubscriptionFactory = GeneralStorageSubscriptionFactory( + chainRegistry: ChainRegistryFacade.sharedRegistry, + storageFacade: SubstrateDataStorageFacade.shared, + operationManager: OperationManager(operationQueue: OperationManagerFacade.sharedDefaultQueue), + logger: Logger.shared + ) + + guard let interactor = createInteractor(with: generalLocalSubscriptionFactory) else { + return nil + } + + let wireframe = SwapSetupWireframe( + assetListObservable: assetListObservable, + state: generalLocalSubscriptionFactory, + swapCompletionClosure: swapCompletionClosure + ) + + let issuesViewModelFactory = SwapIssueViewModelFactory( + balanceViewModelFactoryFacade: balanceViewModelFactoryFacade + ) + + let viewModelFactory = SwapsSetupViewModelFactory( + balanceViewModelFactoryFacade: balanceViewModelFactoryFacade, + issuesViewModelFactory: issuesViewModelFactory, + networkViewModelFactory: NetworkViewModelFactory(), + percentForamatter: NumberFormatter.percentSingle.localizableResource(), + priceDifferenceConfig: .defaultConfig + ) + + let dataValidatingFactory = SwapDataValidatorFactory( + presentable: wireframe, + balanceViewModelFactoryFacade: balanceViewModelFactoryFacade + ) + + let presenter = SwapSetupPresenter( + initState: initState, + interactor: interactor, + wireframe: wireframe, + viewModelFactory: viewModelFactory, + dataValidatingFactory: dataValidatingFactory, + localizationManager: LocalizationManager.shared, + selectedWallet: selectedWallet, + slippageConfig: .defaultConfig, + logger: Logger.shared + ) + + let view = SwapSetupViewController( + presenter: presenter, + localizationManager: LocalizationManager.shared + ) + + presenter.view = view + interactor.basePresenter = presenter + dataValidatingFactory.view = view + + return view + } + + private static func createInteractor( + with generalSubscriptionFactory: GeneralStorageSubscriptionFactoryProtocol + ) -> SwapSetupInteractor? { + guard let currencyManager = CurrencyManager.shared, + let selectedWallet = SelectedWalletSettings.shared.value else { + return nil + } + + let chainRegistry = ChainRegistryFacade.sharedRegistry + let operationQueue = OperationManagerFacade.sharedDefaultQueue + + let assetConversionAggregator = AssetConversionAggregationFactory( + chainRegistry: chainRegistry, + operationQueue: operationQueue + ) + + let feeService = AssetHubFeeService( + wallet: selectedWallet, + chainRegistry: chainRegistry, + operationQueue: operationQueue + ) + + let assetStorageFactory = AssetStorageInfoOperationFactory( + chainRegistry: chainRegistry, + operationQueue: operationQueue + ) + + let interactor = SwapSetupInteractor( + assetConversionAggregatorFactory: assetConversionAggregator, + assetConversionFeeService: feeService, + chainRegistry: ChainRegistryFacade.sharedRegistry, + assetStorageFactory: assetStorageFactory, + priceLocalSubscriptionFactory: PriceProviderFactory.shared, + walletLocalSubscriptionFactory: WalletLocalSubscriptionFactory.shared, + generalLocalSubscriptionFactory: generalSubscriptionFactory, + storageRepository: SubstrateRepositoryFactory().createChainStorageItemRepository(), + currencyManager: currencyManager, + selectedWallet: selectedWallet, + operationQueue: operationQueue + ) + + return interactor + } +} diff --git a/novawallet/Modules/Swaps/Setup/SwapSetupWireframe.swift b/novawallet/Modules/Swaps/Setup/SwapSetupWireframe.swift new file mode 100644 index 0000000000..fa3b1ed964 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/SwapSetupWireframe.swift @@ -0,0 +1,195 @@ +import Foundation +import SoraFoundation +import SoraUI + +final class SwapSetupWireframe: SwapSetupWireframeProtocol { + let assetListObservable: AssetListModelObservable + let state: GeneralStorageSubscriptionFactoryProtocol + let swapCompletionClosure: SwapCompletionClosure? + + init( + assetListObservable: AssetListModelObservable, + state: GeneralStorageSubscriptionFactoryProtocol, + swapCompletionClosure: SwapCompletionClosure? + ) { + self.assetListObservable = assetListObservable + self.state = state + self.swapCompletionClosure = swapCompletionClosure + } + + func showPayTokenSelection( + from view: ControllerBackedProtocol?, + chainAsset: ChainAsset?, + completionHandler: @escaping (ChainAsset) -> Void + ) { + guard let selectTokenView = SwapAssetsOperationViewFactory.createSelectPayTokenView( + for: assetListObservable, + chainAsset: chainAsset, + selectClosure: completionHandler + ) else { + return + } + + let navigationController = NovaNavigationController( + rootViewController: selectTokenView.controller + ) + + view?.controller.present(navigationController, animated: true, completion: nil) + } + + func showReceiveTokenSelection( + from view: ControllerBackedProtocol?, + chainAsset: ChainAsset?, + completionHandler: @escaping (ChainAsset) -> Void + ) { + guard let selectTokenView = SwapAssetsOperationViewFactory.createSelectReceiveTokenView( + for: assetListObservable, + chainAsset: chainAsset, + selectClosure: completionHandler + ) else { + return + } + + let navigationController = NovaNavigationController( + rootViewController: selectTokenView.controller + ) + + view?.controller.present(navigationController, animated: true, completion: nil) + } + + func showSettings( + from view: ControllerBackedProtocol?, + percent: BigRational?, + chainAsset: ChainAsset, + completionHandler: @escaping (BigRational) -> Void + ) { + guard let settingsView = SwapSlippageViewFactory.createView( + percent: percent, + chainAsset: chainAsset, + completionHandler: completionHandler + ) else { + return + } + + view?.controller.navigationController?.pushViewController( + settingsView.controller, + animated: true + ) + } + + func showConfirmation( + from view: ControllerBackedProtocol?, + initState: SwapConfirmInitState + ) { + guard let confimView = SwapConfirmViewFactory.createView( + initState: initState, + generalSubscriptonFactory: state, + completionClosure: swapCompletionClosure + ) else { + return + } + + view?.controller.navigationController?.pushViewController( + confimView.controller, + animated: true + ) + } + + func showNetworkFeeAssetSelection( + form view: ControllerBackedProtocol?, + viewModel: SwapNetworkFeeSheetViewModel + ) { + let bottomSheet = SwapNetworkFeeSheetViewFactory.createView(from: viewModel) + + let factory = ModalSheetPresentationFactory(configuration: ModalSheetPresentationConfiguration.nova) + + bottomSheet.controller.modalTransitioningFactory = factory + bottomSheet.controller.modalPresentationStyle = .custom + + view?.controller.present(bottomSheet.controller, animated: true) + } + + func showGetTokenOptions( + form view: ControllerBackedProtocol?, + purchaseHadler: PurchaseFlowManaging, + destinationChainAsset: ChainAsset, + locale: Locale + ) { + let completion: GetTokenOptionsCompletion = { [weak self, weak purchaseHadler] result in + guard let self = self else { + return + } + + switch result { + case let .crosschains(origins, xcmTransfers): + self.showGetTokensByCrosschain( + from: view, + origins: origins, + destination: destinationChainAsset, + xcmTransfers: xcmTransfers + ) + case let .receive(account): + self.showGetTokensByReceive( + from: view, + chainAsset: destinationChainAsset, + metaChainAccountResponse: account + ) + case let .buy(actions): + purchaseHadler?.startPuchaseFlow( + from: view, + purchaseActions: actions, + wireframe: self, + locale: locale + ) + } + } + + guard let bottomSheet = GetTokenOptionsViewFactory.createView( + from: destinationChainAsset, + assetModelObservable: assetListObservable, + completion: completion + ) else { + return + } + + view?.controller.present(bottomSheet.controller, animated: true) + } + + func showGetTokensByCrosschain( + from view: ControllerBackedProtocol?, + origins: [ChainAsset], + destination: ChainAsset, + xcmTransfers: XcmTransfers + ) { + guard let transferView = TransferSetupViewFactory.createCrosschainView( + from: origins, + to: destination, + xcmTransfers: xcmTransfers, + assetListObservable: assetListObservable, + transferCompletion: nil + ) else { + return + } + + let navigationController = NovaNavigationController(rootViewController: transferView.controller) + + view?.controller.present(navigationController, animated: true) + } + + func showGetTokensByReceive( + from view: ControllerBackedProtocol?, + chainAsset: ChainAsset, + metaChainAccountResponse: MetaChainAccountResponse + ) { + guard let receiveTokensView = AssetReceiveViewFactory.createView( + chainAsset: chainAsset, + metaChainAccountResponse: metaChainAccountResponse + ) else { + return + } + + let navigationController = NovaNavigationController(rootViewController: receiveTokensView.controller) + + view?.controller.present(navigationController, animated: true) + } +} diff --git a/novawallet/Modules/Swaps/Setup/View/SwapAmountInput.swift b/novawallet/Modules/Swaps/Setup/View/SwapAmountInput.swift new file mode 100644 index 0000000000..c8fee61ad8 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/View/SwapAmountInput.swift @@ -0,0 +1,189 @@ +import SoraUI + +final class SwapAmountInput: BackgroundedContentControl { + let textField: UITextField = .create { + $0.font = .title2 + $0.textColor = R.color.colorTextPrimary() + $0.tintColor = R.color.colorTextPrimary() + $0.textAlignment = .right + $0.attributedPlaceholder = NSAttributedString( + string: "0", + attributes: [ + .foregroundColor: R.color.colorHintText()!, + .font: UIFont.title2 + ] + ) + $0.keyboardType = .decimalPad + } + + let priceLabel = UILabel( + style: .footnoteSecondary, + textAlignment: .right, + numberOfLines: 1 + ) + + let spacing: CGFloat = 4 + + override var intrinsicContentSize: CGSize { + let contentHeight = textField.intrinsicContentSize.height + spacing + priceLabel.intrinsicContentSize.height + + let height = contentInsets.top + contentHeight + contentInsets.bottom + + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } + + private(set) var inputViewModel: AmountInputViewModelProtocol? + + var completed: Bool { + inputViewModel?.isValid == true + } + + var hasValidNumber: Bool { + inputViewModel?.decimalAmount != nil + } + + override public init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + contentView?.frame = bounds + + layoutContent() + } + + private func layoutContent() { + let availableWidth = bounds.width - contentInsets.left - contentInsets.right + let textFieldWidth = max(availableWidth, 0) + + let textFieldHeight: CGFloat = textField.intrinsicContentSize.height + let textFieldY: CGFloat + + if !priceLabel.text.isNilOrEmpty { + textFieldY = bounds.midY - textFieldHeight + spacing + } else { + textFieldY = bounds.midY - textFieldHeight / 2 + } + + textField.frame = CGRect( + x: bounds.maxX - contentInsets.right - textFieldWidth, + y: textFieldY, + width: textFieldWidth, + height: textFieldHeight + ) + + priceLabel.frame = CGRect( + x: bounds.maxX - contentInsets.right - textFieldWidth, + y: textField.frame.maxY, + width: textFieldWidth, + height: priceLabel.intrinsicContentSize.height + ) + } + + private func configure() { + backgroundColor = UIColor.clear + + configureContentViewIfNeeded() + configureLocalHandlers() + } + + private func configureLocalHandlers() { + addTarget(self, action: #selector(actionTouchUpInside), for: .touchUpInside) + textField.delegate = self + } + + private func configureContentViewIfNeeded() { + if contentView == nil { + let contentView = UIView() + contentView.backgroundColor = .clear + contentView.isUserInteractionEnabled = false + self.contentView = contentView + } + + contentView?.addSubview(priceLabel) + contentView?.addSubview(textField) + } + + @objc private func actionTouchUpInside() { + textField.becomeFirstResponder() + } +} + +extension SwapAmountInput: UITextFieldDelegate { + func textField( + _: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + inputViewModel?.didReceiveReplacement(string, for: range) ?? false + } +} + +extension SwapAmountInput: AmountInputViewModelObserver { + func amountInputDidChange() { + textField.text = inputViewModel?.displayAmount + + if textField.isEditing { + sendActions(for: .editingChanged) + } + } +} + +extension SwapAmountInput { + func bind(inputViewModel: AmountInputViewModelProtocol) { + self.inputViewModel?.observable.remove(observer: self) + inputViewModel.observable.add(observer: self) + + self.inputViewModel = inputViewModel + textField.text = inputViewModel.displayAmount + } + + func bind(priceViewModel: String?) { + priceLabel.text = priceViewModel + setNeedsLayout() + } + + func bind(priceDifferenceViewModel: SwapPriceDifferenceViewModel?) { + let priceString = NSMutableAttributedString() + if let price = priceDifferenceViewModel?.price { + priceString.append(.init( + string: price, + attributes: [ + .font: UIFont.regularFootnote, + .foregroundColor: R.color.colorTextSecondary()! + ] + )) + } + if let difference = priceDifferenceViewModel?.difference { + priceString.append(.init( + string: " " + difference.details, + attributes: [ + .font: UIFont.regularFootnote, + .foregroundColor: color(for: difference.attention) + ] + )) + } + + priceLabel.attributedText = priceString + setNeedsLayout() + } + + private func color(for attention: AttentionState) -> UIColor { + switch attention { + case .high: + return R.color.colorTextNegative()! + case .medium: + return R.color.colorTextWarning()! + case .low: + return R.color.colorTextSecondary()! + } + } +} diff --git a/novawallet/Modules/Swaps/Setup/View/SwapAmountInputView.swift b/novawallet/Modules/Swaps/Setup/View/SwapAmountInputView.swift new file mode 100644 index 0000000000..665e52e271 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/View/SwapAmountInputView.swift @@ -0,0 +1,194 @@ +import SoraUI + +final class SwapAmountInputView: RoundedView { + let assetControl = SwapAssetControl() + let textInputView = SwapAmountInput() + private var style: Style = .normal + + var contentInsets = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 16) { + didSet { + setNeedsLayout() + } + } + + var horizontalSpacing: CGFloat = 8 { + didSet { + setNeedsLayout() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + let leftContentHeight = assetControl.intrinsicContentSize.height + let rightContentHeight = textInputView.intrinsicContentSize.height + + let contentHeight = max(leftContentHeight, rightContentHeight) + + let height = contentInsets.top + contentHeight + contentInsets.bottom + + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } + + // MARK: Layout + + override func layoutSubviews() { + super.layoutSubviews() + + assetControl.frame = swapAssetControlFrame(bounds: bounds) + textInputView.frame = inputViewFrame( + bounds: bounds, + assetControlFrame: assetControl.frame + ) + } + + private func swapAssetControlFrame(bounds: CGRect) -> CGRect { + let availableWidth = bounds.width - contentInsets.left - contentInsets.right + let swapAssetControlSize = assetControl.intrinsicContentSize + + let width = textInputView.isHidden ? availableWidth : + min(min(availableWidth, swapAssetControlSize.width), 0.7 * availableWidth) + + return CGRect( + x: contentInsets.left, + y: bounds.midY - swapAssetControlSize.height / 2.0, + width: width, + height: swapAssetControlSize.height + ) + } + + private func inputViewFrame( + bounds: CGRect, + assetControlFrame: CGRect + ) -> CGRect { + let estimatedInputViewWidth = bounds.width - contentInsets.left - assetControlFrame.width - contentInsets.right - horizontalSpacing + + let inputWidth = max(estimatedInputViewWidth, 0) + let inputSize = textInputView.intrinsicContentSize + + return CGRect( + x: assetControlFrame.maxX + horizontalSpacing, + y: bounds.midY - inputSize.height / 2.0, + width: inputWidth, + height: inputSize.height + ) + } + + // MARK: Configure + + override func configure() { + super.configure() + + backgroundColor = UIColor.clear + apply(style: .strokeOnEditing) + + configureContent() + configureInputViewHandlers() + } + + private func configureContent() { + addSubview(assetControl) + addSubview(textInputView) + + assetControl.contentInsets = .zero + textInputView.contentInsets = .zero + } + + private func configureInputViewHandlers() { + textInputView.textField.addTarget( + self, + action: #selector(actionEditingDidBeginEnd), + for: .editingDidBegin + ) + + textInputView.textField.addTarget( + self, + action: #selector(actionEditingDidBeginEnd), + for: .editingDidEnd + ) + } + + private func updateFocusState() { + switch style { + case .error: + strokeWidth = 0.5 + case .normal: + strokeWidth = textInputView.textField.isFirstResponder ? 0.5 : 0.0 + } + } + + @objc private func actionEditingDidBeginEnd() { + updateFocusState() + } +} + +extension SwapAmountInputView { + func bind(assetViewModel: SwapsAssetViewModel) { + textInputView.isHidden = false + assetControl.bind(assetViewModel: assetViewModel) + setNeedsLayout() + } + + func bind(emptyViewModel: EmptySwapsAssetViewModel) { + textInputView.isHidden = true + assetControl.bind(emptyViewModel: emptyViewModel) + setNeedsLayout() + } + + func bind(inputViewModel: AmountInputViewModelProtocol) { + textInputView.isHidden = false + textInputView.bind(inputViewModel: inputViewModel) + } + + func bind(priceViewModel: String?) { + textInputView.bind(priceViewModel: priceViewModel) + setNeedsLayout() + } + + func bind(priceDifferenceViewModel: SwapPriceDifferenceViewModel?) { + textInputView.bind(priceDifferenceViewModel: priceDifferenceViewModel) + setNeedsLayout() + } + + func set(focused: Bool) { + guard !textInputView.isHidden else { + return + } + if focused { + textInputView.textField.becomeFirstResponder() + } else { + textInputView.textField.resignFirstResponder() + } + } +} + +extension SwapAmountInputView { + enum Style { + case normal + case error + } + + func applyInput(style: Style) { + switch style { + case .error: + apply(style: .strokeOnError) + textInputView.textField.textColor = R.color.colorTextNegative() + textInputView.textField.tintColor = R.color.colorTextNegative() + case .normal: + apply(style: .strokeOnEditing) + textInputView.textField.textColor = R.color.colorTextPrimary() + textInputView.textField.tintColor = R.color.colorTextPrimary() + } + self.style = style + + updateFocusState() + } +} diff --git a/novawallet/Modules/Swaps/Setup/View/SwapAssetControl.swift b/novawallet/Modules/Swaps/Setup/View/SwapAssetControl.swift new file mode 100644 index 0000000000..8a4e70aaa0 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/View/SwapAssetControl.swift @@ -0,0 +1,161 @@ +import UIKit +import SoraUI + +final class SwapAssetControl: BackgroundedContentControl { + var iconView: AssetIconView { lazyIconViewOrCreateIfNeeded() } + private var lazyIconView: AssetIconView? + + let assetView = SwapAssetView() + + var iconViewContentInsets = UIEdgeInsets.zero { + didSet { + lazyIconView?.contentInsets = iconViewContentInsets + setNeedsLayout() + } + } + + var horizontalSpacing: CGFloat = 12 { + didSet { + setNeedsLayout() + } + } + + var iconRadius: CGFloat = 16 { + didSet { + lazyIconView?.backgroundView.cornerRadius = iconRadius + + setNeedsLayout() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + let contentHeight = max( + lazyIconView?.intrinsicContentSize.height ?? 0.0, + assetView.intrinsicContentSize.height + ) + + let iconWidth: CGFloat + if lazyIconView == nil { + iconWidth = 0 + } else { + iconWidth = 2 * iconRadius + horizontalSpacing + } + + let contentWidth = iconWidth + assetView.intrinsicContentSize.width + + let height = contentInsets.top + contentHeight + contentInsets.bottom + let width = contentInsets.left + contentWidth + contentInsets.right + + return CGSize(width: width, height: height) + } + + override func layoutSubviews() { + super.layoutSubviews() + + contentView?.frame = bounds + + layoutContent() + } + + private func layoutContent() { + let availableWidth = bounds.width - contentInsets.left - contentInsets.right + let assetViewSize = assetView.intrinsicContentSize + + if let iconView = lazyIconView { + iconView.frame = CGRect( + x: bounds.minX + contentInsets.left, + y: bounds.midY - iconRadius, + width: 2.0 * iconRadius, + height: 2.0 * iconRadius + ) + + assetView.frame = CGRect( + x: iconView.frame.maxX + horizontalSpacing, + y: bounds.midY - assetViewSize.height / 2.0, + width: min(availableWidth, assetViewSize.width), + height: assetViewSize.height + ) + } else { + assetView.frame = CGRect( + x: contentInsets.left, + y: bounds.midY - assetViewSize.height / 2.0, + width: min(availableWidth, assetViewSize.width), + height: assetViewSize.height + ) + } + } + + private func configure() { + backgroundColor = UIColor.clear + + if contentView == nil { + let contentView = UIView() + contentView.backgroundColor = .clear + contentView.isUserInteractionEnabled = false + self.contentView = contentView + } + + contentView?.addSubview(assetView) + changesContentOpacityWhenHighlighted = true + } + + private func lazyIconViewOrCreateIfNeeded() -> AssetIconView { + if let iconView = lazyIconView { + return iconView + } + + let size = 2 * iconRadius + let initFrame = CGRect(origin: .zero, size: .init(width: size, height: size)) + let imageView = AssetIconView(frame: initFrame) + imageView.contentInsets = iconViewContentInsets + imageView.backgroundView.cornerRadius = iconRadius + contentView?.addSubview(imageView) + + lazyIconView = imageView + + if superview != nil { + setNeedsLayout() + } + + return imageView + } +} + +extension SwapAssetControl { + func bind(assetViewModel: SwapsAssetViewModel) { + let width = 2 * iconRadius - iconView.contentInsets.left - iconView.contentInsets.right + let height = 2 * iconRadius - iconView.contentInsets.top - iconView.contentInsets.bottom + iconViewContentInsets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4) + let size = CGSize(width: width, height: height) + iconView.bind(viewModel: assetViewModel.imageViewModel, size: size) + + assetView.bind( + symbol: assetViewModel.symbol, + network: assetViewModel.hub.name, + icon: assetViewModel.hub.icon + ) + invalidateLayout() + } + + func bind(emptyViewModel: EmptySwapsAssetViewModel) { + let size = CGSize(width: 2 * iconRadius, height: 2 * iconRadius) + iconView.bind(viewModel: emptyViewModel.imageViewModel, size: size) + iconViewContentInsets = .zero + assetView.bind( + symbol: emptyViewModel.title, + network: emptyViewModel.subtitle, + icon: nil + ) + invalidateLayout() + } +} diff --git a/novawallet/Modules/Swaps/Setup/View/SwapAssetView.swift b/novawallet/Modules/Swaps/Setup/View/SwapAssetView.swift new file mode 100644 index 0000000000..27e0a8f271 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/View/SwapAssetView.swift @@ -0,0 +1,80 @@ +import UIKit + +typealias SwapIconDetailsView = GenericPairValueView + +final class SwapAssetView: GenericPairValueView { + var assetLabel: UILabel { fView.fView.detailsLabel } + var disclosureImageView: UIImageView { fView.fView.imageView } + var hubNameView: UILabel { sView.detailsLabel } + var hubImageView: UIImageView { sView.imageView } + + private var imageViewModel: ImageViewModelProtocol? + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configure() { + fView.makeHorizontal() + fView.fView.spacing = 0 + fView.fView.iconWidth = 20 + fView.fView.mode = .detailsIcon + hubNameView.numberOfLines = 1 + assetLabel.numberOfLines = 1 + + sView.spacing = 8 + sView.iconWidth = 16 + sView.mode = .iconDetails + + spacing = 4 + makeVertical() + + assetLabel.apply(style: .semiboldBodyPrimary) + hubNameView.apply(style: .footnoteSecondary) + } + + override var intrinsicContentSize: CGSize { + let assetViewWidth = assetLabel.intrinsicContentSize.width + iconWidth + fView.fView.iconWidth + let hubViewWidth = iconWidth + iconSpacing + hubNameView.intrinsicContentSize.width + let width: CGFloat = max(assetViewWidth, hubViewWidth) + let assetHeight = max(assetLabel.intrinsicContentSize.height, iconWidth) + let hubViewHeight = max(hubNameView.intrinsicContentSize.height, iconWidth) + let height = assetHeight + spacing + hubViewHeight + return .init( + width: width, + height: height + ) + } + + private var iconWidth: CGFloat { + imageViewModel == nil ? 0 : fView.fView.iconWidth + } + + private var iconSpacing: CGFloat { + imageViewModel == nil ? 0 : sView.spacing + } + + func bind(symbol: String, network: String, icon: ImageViewModelProtocol?) { + assetLabel.text = symbol + imageViewModel?.cancel(on: hubImageView) + imageViewModel = icon + icon?.loadImage( + on: hubImageView, + targetSize: .init( + width: sView.iconWidth, + height: sView.iconWidth + ), + animated: true + ) + sView.hidesIcon = icon == nil + hubNameView.text = network + disclosureImageView.image = R.image.iconSmallArrow()?.tinted(with: R.color.colorIconSecondary()!) + invalidateIntrinsicContentSize() + } +} diff --git a/novawallet/Modules/Swaps/Setup/View/SwapDetailsView.swift b/novawallet/Modules/Swaps/Setup/View/SwapDetailsView.swift new file mode 100644 index 0000000000..f0b171312a --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/View/SwapDetailsView.swift @@ -0,0 +1,29 @@ +import UIKit + +final class SwapDetailsView: CollapsableContainerView { + let rateCell: SwapInfoViewCell = .create { + $0.titleButton.imageWithTitleView?.titleColor = R.color.colorTextSecondary() + $0.titleButton.imageWithTitleView?.titleFont = .regularFootnote + $0.contentInsets = .init(top: 8, left: 16, bottom: 8, right: 16) + $0.borderView.borderType = .bottom + $0.roundedBackgroundView.cornerRadius = 12 + $0.roundedBackgroundView.roundingCorners = [.topLeft, .topRight] + } + + let networkFeeCell: SwapNetworkFeeViewCell = .create { + $0.contentInsets = .init(top: 8, left: 16, bottom: 8, right: 16) + $0.borderView.borderType = .none + $0.roundedBackgroundView.cornerRadius = 12 + $0.roundedBackgroundView.roundingCorners = [.bottomLeft, .bottomRight] + } + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundView.sideLength = 12 + } + + override var rows: [UIView] { + [rateCell, networkFeeCell] + } +} diff --git a/novawallet/Modules/Swaps/Setup/View/SwapMaxButtonView.swift b/novawallet/Modules/Swaps/Setup/View/SwapMaxButtonView.swift new file mode 100644 index 0000000000..37f2f76681 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/View/SwapMaxButtonView.swift @@ -0,0 +1,36 @@ +import UIKit +import SoraUI + +typealias SwapSetupTitleButton = ControlView> +final class SwapSetupTitleView: GenericTitleValueView { + var titleLabel: UILabel { titleView } + var button: SwapSetupTitleButton { valueView } + var buttonTitle: UILabel { button.controlContentView.fView } + var buttonValue: UILabel { button.controlContentView.sView } + + override init(frame: CGRect) { + super.init(frame: frame) + + configure() + button.backgroundView?.backgroundColor = .clear + } + + private func configure() { + titleView.apply(style: .footnoteSecondary) + buttonTitle.apply(style: .footnoteAccentText) + buttonValue.apply(style: .footnotePrimary) + button.controlContentView.spacing = 4 + button.controlContentView.stackView.axis = .horizontal + button.contentInsets = UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0) + button.changesContentOpacityWhenHighlighted = true + } +} + +extension SwapSetupTitleView { + func bind(model: TitleHorizontalMultiValueView.Model) { + titleView.text = model.title + buttonTitle.text = model.subtitle + buttonValue.text = model.value + button.invalidateLayout() + } +} diff --git a/novawallet/Modules/Swaps/Setup/View/SwapSetupViewLayout.swift b/novawallet/Modules/Swaps/Setup/View/SwapSetupViewLayout.swift new file mode 100644 index 0000000000..f72eeb9212 --- /dev/null +++ b/novawallet/Modules/Swaps/Setup/View/SwapSetupViewLayout.swift @@ -0,0 +1,215 @@ +import UIKit +import SoraUI + +final class SwapSetupViewLayout: ScrollableContainerLayoutView { + let payAmountView = SwapSetupTitleView(frame: .zero) + + let payAmountInputView = SwapAmountInputView() + + let depositTokenButton: TriangularedButton = .create { + $0.applySecondaryDefaultStyle() + $0.imageWithTitleView?.titleColor = R.color.colorButtonTextAccent() + } + + let receiveAmountView: TitleHorizontalMultiValueView = .create { + $0.titleView.apply(style: .footnoteSecondary) + $0.detailsTitleLabel.apply(style: .footnoteSecondary) + $0.detailsValueLabel.apply(style: .footnotePrimary) + } + + let receiveAmountInputView = SwapAmountInputView() + + let loadableActionView = LoadableActionView() + + var actionButton: TriangularedButton { + loadableActionView.actionButton + } + + let switchButton: RoundedButton = .create { + $0.applyIconStyle() + $0.imageWithTitleView?.iconImage = R.image.iconActionSwap() + } + + let detailsView: SwapDetailsView = .create { + $0.contentInsets = .zero + $0.setExpanded(false, animated: false) + } + + var rateCell: SwapInfoViewCell { + detailsView.rateCell + } + + var networkFeeCell: SwapNetworkFeeViewCell { + detailsView.networkFeeCell + } + + var payIssueLabel: UILabel? + + var receiveIssueLabel: UILabel? + + var notificationView: InlineAlertView? + + private func setupPayIssueLabel() -> UILabel { + if let payIssueLabel = payIssueLabel { + return payIssueLabel + } + + let label = UILabel(style: .caption1Negative) + label.numberOfLines = 0 + + insertArrangedSubview(label, after: payAmountInputView, spacingAfter: 8) + stackView.setCustomSpacing(8, after: payAmountInputView) + + payIssueLabel = label + + return label + } + + private func setupReceiveIssueLabel() -> UILabel { + if let receiveIssueLabel = receiveIssueLabel { + return receiveIssueLabel + } + + let label = UILabel(style: .caption1Negative) + label.numberOfLines = 0 + + insertArrangedSubview(label, after: receiveAmountInputView, spacingAfter: 8) + stackView.setCustomSpacing(8, after: receiveAmountInputView) + + receiveIssueLabel = label + + return label + } + + private func setupNotificationView() -> InlineAlertView { + if let notificationView = notificationView { + return notificationView + } + + let view = InlineAlertView.info() + insertArrangedSubview(view, after: detailsView, spacingAfter: 8) + stackView.setCustomSpacing(16, after: detailsView) + + notificationView = view + + return view + } + + override func setupStyle() { + backgroundColor = R.color.colorSecondaryScreenBackground() + } + + override func setupLayout() { + super.setupLayout() + + stackView.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 0, right: 16) + + addSubview(loadableActionView) + loadableActionView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(UIConstants.horizontalInset) + make.bottom.equalTo(safeAreaLayoutGuide).inset(UIConstants.actionBottomInset) + make.height.equalTo(UIConstants.actionHeight) + } + + addArrangedSubview(payAmountView, spacingAfter: 8) + payAmountView.snp.makeConstraints { + $0.height.equalTo(18) + } + addArrangedSubview(payAmountInputView, spacingAfter: 12) + payAmountInputView.snp.makeConstraints { + $0.height.equalTo(64) + } + addArrangedSubview(depositTokenButton, spacingAfter: 24) + depositTokenButton.snp.makeConstraints { + $0.height.equalTo(44) + } + addArrangedSubview(receiveAmountView, spacingAfter: 8) + receiveAmountView.snp.makeConstraints { + $0.height.equalTo(18) + } + addArrangedSubview(receiveAmountInputView, spacingAfter: 16) + receiveAmountInputView.snp.makeConstraints { + $0.height.equalTo(64) + } + + addArrangedSubview(detailsView, spacingAfter: 8) + + addSubview(switchButton) + switchButton.snp.makeConstraints { + $0.height.equalTo(switchButton.snp.width) + $0.bottom.equalTo(receiveAmountInputView.snp.top).offset(-4) + $0.centerX.equalTo(payAmountInputView.snp.centerX) + } + } + + func setup(locale: Locale) { + detailsView.titleControl.titleLabel.text = R.string.localizable.swapsSetupDetailsTitle( + preferredLanguages: locale.rLanguages + ) + rateCell.titleButton.imageWithTitleView?.title = R.string.localizable.swapsSetupDetailsRate( + preferredLanguages: locale.rLanguages) + networkFeeCell.titleButton.imageWithTitleView?.title = R.string.localizable.commonNetworkFee( + preferredLanguages: locale.rLanguages) + rateCell.titleButton.invalidateLayout() + networkFeeCell.titleButton.invalidateLayout() + } + + func changeDepositTokenButtonVisibility(hidden: Bool) { + if hidden { + stackView.setCustomSpacing(24, after: payAmountInputView) + } else { + stackView.setCustomSpacing(12, after: payAmountInputView) + } + depositTokenButton.isHidden = hidden + setNeedsLayout() + } + + func hideIssues() { + hidePayIssue() + hideReceiveIssue() + } + + func displayPayIssue(with text: String) { + let payIssueLabel = setupPayIssueLabel() + payIssueLabel.text = text + + payAmountInputView.applyInput(style: .error) + } + + func hidePayIssue() { + payIssueLabel?.removeFromSuperview() + payIssueLabel = nil + + stackView.setCustomSpacing(12, after: payAmountInputView) + + payAmountInputView.applyInput(style: .normal) + } + + func displayReceiveIssue(with text: String) { + let receiveIssueLabel = setupReceiveIssueLabel() + receiveIssueLabel.text = text + + receiveAmountInputView.applyInput(style: .error) + } + + func hideReceiveIssue() { + receiveIssueLabel?.removeFromSuperview() + receiveIssueLabel = nil + + stackView.setCustomSpacing(16, after: receiveAmountInputView) + + receiveAmountInputView.applyInput(style: .normal) + } + + func displayInfoNotification(with text: String) { + let notificationView = setupNotificationView() + notificationView.contentView.detailsLabel.text = text + } + + func hideNotification() { + notificationView?.removeFromSuperview() + notificationView = nil + + stackView.setCustomSpacing(8, after: detailsView) + } +} diff --git a/novawallet/Modules/Swaps/Slippage/AmountInputViewModel+Slippage.swift b/novawallet/Modules/Swaps/Slippage/AmountInputViewModel+Slippage.swift new file mode 100644 index 0000000000..97af68c178 --- /dev/null +++ b/novawallet/Modules/Swaps/Slippage/AmountInputViewModel+Slippage.swift @@ -0,0 +1,18 @@ +import Foundation + +extension AmountInputViewModel { + static func forAssetConversionSlippage(for amount: Decimal?, locale: Locale) -> AmountInputViewModel { + let precision: Int16 = 4 + let numberFormatter = NumberFormatter.amount.localizableResource().value(for: locale) + numberFormatter.maximumFractionDigits = Int(precision) + numberFormatter.maximumSignificantDigits = Int(precision) + + return .init( + symbol: "", + amount: amount, + limit: 100, + formatter: numberFormatter, + precision: precision + ) + } +} diff --git a/novawallet/Modules/Swaps/Slippage/Model/SlippagePercentViewModel.swift b/novawallet/Modules/Swaps/Slippage/Model/SlippagePercentViewModel.swift new file mode 100644 index 0000000000..2879f2dbac --- /dev/null +++ b/novawallet/Modules/Swaps/Slippage/Model/SlippagePercentViewModel.swift @@ -0,0 +1,6 @@ +import Foundation + +struct SlippagePercentViewModel { + let value: Decimal + let title: String +} diff --git a/novawallet/Modules/Swaps/Slippage/SwapSlippagePresenter.swift b/novawallet/Modules/Swaps/Slippage/SwapSlippagePresenter.swift new file mode 100644 index 0000000000..0e61b9f782 --- /dev/null +++ b/novawallet/Modules/Swaps/Slippage/SwapSlippagePresenter.swift @@ -0,0 +1,152 @@ +import Foundation +import SoraFoundation +import BigInt + +final class SwapSlippagePresenter { + weak var view: SwapSlippageViewProtocol? + let wireframe: SwapSlippageWireframeProtocol + let percentFormatterLocalizable: LocalizableResource + let completionHandler: (BigRational) -> Void + let chainAsset: ChainAsset + + let initSlippage: Decimal? + let defaultSlippage: Decimal + let slippageTips: [Decimal] + let bounds: SlippageBounds + + private var amountInput: Decimal? + + init( + wireframe: SwapSlippageWireframeProtocol, + percentFormatterLocalizable: LocalizableResource, + localizationManager: LocalizationManagerProtocol, + initSlippage: BigRational?, + config: SlippageConfig, + chainAsset: ChainAsset, + completionHandler: @escaping (BigRational) -> Void + ) { + self.wireframe = wireframe + self.percentFormatterLocalizable = percentFormatterLocalizable + self.initSlippage = initSlippage?.decimalValue + defaultSlippage = config.defaultSlippage.decimalOrZeroValue + bounds = .init(config: config) + slippageTips = config.slippageTips.map(\.decimalOrZeroValue) + + self.chainAsset = chainAsset + self.completionHandler = completionHandler + self.localizationManager = localizationManager + } + + private func provideTips() { + let formatter = percentFormatterLocalizable.value(for: selectedLocale) + + let tips = slippageTips.map { + SlippagePercentViewModel( + value: $0, + title: formatter.stringFromDecimal($0) ?? "" + ) + } + + view?.didReceivePreFilledPercents(viewModel: tips) + } + + private func percentToString(from decimal: Decimal) -> String { + percentFormatterLocalizable + .value(for: selectedLocale) + .stringFromDecimal(decimal) ?? "" + } + + private func provideAmountViewModel() { + let inputViewModel = AmountInputViewModel.forAssetConversionSlippage( + for: amountInput?.fromFractionToPercents(), + locale: selectedLocale + ) + + view?.didReceiveInput(viewModel: inputViewModel) + } + + private func provideButtonStates() { + let error = bounds.error( + for: amountInput, + stringAmountClosure: percentToString, + locale: selectedLocale + ) + + let canReset = amountInput != defaultSlippage + view?.didReceiveResetState(available: canReset) + + let canApply = amountInput != initSlippage && error == nil + view?.didReceiveButtonState(available: canApply) + } + + private func provideErrors() { + let error = bounds.error( + for: amountInput, + stringAmountClosure: percentToString, + locale: selectedLocale + ) + view?.didReceiveInput(error: error) + provideButtonStates() + } + + private func provideWarnings() { + let warning = bounds.warning(for: amountInput, locale: selectedLocale) + view?.didReceiveInput(warning: warning) + } + + private func updateView() { + provideAmountViewModel() + provideButtonStates() + provideWarnings() + provideErrors() + provideTips() + } +} + +extension SwapSlippagePresenter: SwapSlippagePresenterProtocol { + func setup() { + amountInput = initSlippage + updateView() + } + + func select(percent: SlippagePercentViewModel) { + amountInput = percent.value + provideAmountViewModel() + provideButtonStates() + provideErrors() + provideWarnings() + } + + func updateAmount(_ amount: Decimal?) { + amountInput = amount?.fromPercentsToFraction() + provideButtonStates() + provideErrors() + provideWarnings() + } + + func showSlippageInfo() { + wireframe.showSlippageInfo(from: view) + } + + func reset() { + amountInput = defaultSlippage + provideAmountViewModel() + provideButtonStates() + provideErrors() + provideWarnings() + } + + func apply() { + if let amountInput = amountInput, + let rational = BigRational.fraction(from: amountInput) { + completionHandler(rational) + wireframe.close(from: view) + } + } +} + +extension SwapSlippagePresenter: Localizable { + func applyLocalization() { + updateView() + } +} diff --git a/novawallet/Modules/Swaps/Slippage/SwapSlippageProtocols.swift b/novawallet/Modules/Swaps/Slippage/SwapSlippageProtocols.swift new file mode 100644 index 0000000000..c84ec8b9ef --- /dev/null +++ b/novawallet/Modules/Swaps/Slippage/SwapSlippageProtocols.swift @@ -0,0 +1,23 @@ +import Foundation + +protocol SwapSlippageViewProtocol: ControllerBackedProtocol { + func didReceivePreFilledPercents(viewModel: [SlippagePercentViewModel]) + func didReceiveInput(viewModel: AmountInputViewModelProtocol) + func didReceiveInput(error: String?) + func didReceiveInput(warning: String?) + func didReceiveResetState(available: Bool) + func didReceiveButtonState(available: Bool) +} + +protocol SwapSlippagePresenterProtocol: AnyObject { + func setup() + func select(percent: SlippagePercentViewModel) + func updateAmount(_ amount: Decimal?) + func apply() + func showSlippageInfo() + func reset() +} + +protocol SwapSlippageWireframeProtocol: AnyObject, ShortTextInfoPresentable { + func close(from view: ControllerBackedProtocol?) +} diff --git a/novawallet/Modules/Swaps/Slippage/SwapSlippageViewController.swift b/novawallet/Modules/Swaps/Slippage/SwapSlippageViewController.swift new file mode 100644 index 0000000000..516931d499 --- /dev/null +++ b/novawallet/Modules/Swaps/Slippage/SwapSlippageViewController.swift @@ -0,0 +1,148 @@ +import UIKit +import SoraFoundation + +final class SwapSlippageViewController: UIViewController, ViewHolder { + typealias RootViewType = SwapSlippageViewLayout + + let presenter: SwapSlippagePresenterProtocol + private var isApplyAvailable: Bool = false + + init( + presenter: SwapSlippagePresenterProtocol, + localizationManager: LocalizationManagerProtocol + ) { + self.presenter = presenter + super.init(nibName: nil, bundle: nil) + self.localizationManager = localizationManager + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = SwapSlippageViewLayout() + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupLocalization() + setupHandlers() + setupAccessoryView() + setupNavigationItem() + presenter.setup() + } + + private func setupLocalization() { + let languages = selectedLocale.rLanguages + title = R.string.localizable.swapsSetupSettingsTitle( + preferredLanguages: languages) + rootView.slippageButton.imageWithTitleView?.title = R.string.localizable.swapsSetupSlippage( + preferredLanguages: languages) + rootView.actionButton.imageWithTitleView?.title = R.string.localizable.commonApply( + preferredLanguages: languages) + navigationItem.rightBarButtonItem?.title = R.string.localizable.commonReset( + preferredLanguages: selectedLocale.rLanguages) + } + + private func setupHandlers() { + rootView.amountInput.delegate = self + rootView.actionButton.addTarget(self, action: #selector(applyButtonAction), for: .touchUpInside) + rootView.amountInput.addTarget(self, action: #selector(inputEditingAction), for: .editingChanged) + rootView.slippageButton.addTarget(self, action: #selector(slippageInfoAction), for: .touchUpInside) + } + + private func setupAccessoryView() { + let accessoryView = + UIFactory.default.createDoneAccessoryView( + target: self, + selector: #selector(doneButtonAction), + locale: selectedLocale + ) + rootView.amountInput.textField.inputAccessoryView = accessoryView + } + + private func setupNavigationItem() { + navigationItem.rightBarButtonItem = .init( + title: R.string.localizable.commonReset(preferredLanguages: selectedLocale.rLanguages), + style: .plain, + target: self, + action: #selector(resetAction) + ) + } + + private func updateActionButton() { + let inputValid = rootView.amountInput.inputViewModel?.isValid == true + + let isEnabled = isApplyAvailable && inputValid + rootView.actionButton.set(enabled: isEnabled, changeStyle: true) + } + + @objc private func applyButtonAction() { + presenter.apply() + } + + @objc private func doneButtonAction() { + rootView.amountInput.endEditing(true) + } + + @objc private func inputEditingAction() { + let amount = rootView.amountInput.inputViewModel?.decimalAmount + presenter.updateAmount(amount) + updateActionButton() + } + + @objc private func slippageInfoAction() { + presenter.showSlippageInfo() + } + + @objc private func resetAction() { + presenter.reset() + } +} + +extension SwapSlippageViewController: SwapSlippageViewProtocol { + func didReceivePreFilledPercents(viewModel: [SlippagePercentViewModel]) { + rootView.amountInput.bind(viewModel: viewModel) + } + + func didReceiveInput(viewModel: AmountInputViewModelProtocol) { + rootView.amountInput.bind(inputViewModel: viewModel) + updateActionButton() + } + + func didReceiveResetState(available: Bool) { + navigationItem.rightBarButtonItem?.isEnabled = available + } + + func didReceiveButtonState(available: Bool) { + isApplyAvailable = available + updateActionButton() + } + + func didReceiveInput(error: String?) { + rootView.set(error: error) + updateActionButton() + } + + func didReceiveInput(warning: String?) { + rootView.set(warning: warning) + } +} + +extension SwapSlippageViewController: PercentInputViewDelegateProtocol { + func didSelect(percent: SlippagePercentViewModel, sender _: Any?) { + presenter.select(percent: percent) + updateActionButton() + } +} + +extension SwapSlippageViewController: Localizable { + func applyLocalization() { + if isViewLoaded { + setupLocalization() + } + } +} diff --git a/novawallet/Modules/Swaps/Slippage/SwapSlippageViewFactory.swift b/novawallet/Modules/Swaps/Slippage/SwapSlippageViewFactory.swift new file mode 100644 index 0000000000..531c445b14 --- /dev/null +++ b/novawallet/Modules/Swaps/Slippage/SwapSlippageViewFactory.swift @@ -0,0 +1,37 @@ +import Foundation +import SoraFoundation + +struct SwapSlippageViewFactory { + static func createView( + percent: BigRational?, + chainAsset: ChainAsset, + completionHandler: @escaping (BigRational) -> Void + ) -> SwapSlippageViewProtocol? { + let wireframe = SwapSlippageWireframe() + + let amountFormatter = NumberFormatter.amount + amountFormatter.maximumFractionDigits = 4 + amountFormatter.maximumSignificantDigits = 4 + + let percentFormatter = NumberFormatter.percentSingle + + let presenter = SwapSlippagePresenter( + wireframe: wireframe, + percentFormatterLocalizable: percentFormatter.localizableResource(), + localizationManager: LocalizationManager.shared, + initSlippage: percent, + config: SlippageConfig.defaultConfig, + chainAsset: chainAsset, + completionHandler: completionHandler + ) + + let view = SwapSlippageViewController( + presenter: presenter, + localizationManager: LocalizationManager.shared + ) + + presenter.view = view + + return view + } +} diff --git a/novawallet/Modules/Swaps/Slippage/SwapSlippageViewLayout.swift b/novawallet/Modules/Swaps/Slippage/SwapSlippageViewLayout.swift new file mode 100644 index 0000000000..bdde1b40b2 --- /dev/null +++ b/novawallet/Modules/Swaps/Slippage/SwapSlippageViewLayout.swift @@ -0,0 +1,63 @@ +import UIKit +import SoraUI + +final class SwapSlippageViewLayout: ScrollableContainerLayoutView { + let slippageButton: RoundedButton = .create { + $0.applyIconStyle() + $0.imageWithTitleView?.iconImage = R.image.iconInfoFilled() + $0.imageWithTitleView?.titleColor = R.color.colorTextPrimary() + $0.imageWithTitleView?.titleFont = .semiBoldBody + $0.imageWithTitleView?.spacingBetweenLabelAndIcon = 4 + $0.imageWithTitleView?.layoutType = .horizontalLabelFirst + $0.contentInsets = .init(top: 0, left: 0, bottom: 12, right: 0) + } + + let amountInput = PercentInputView() + + let actionButton: TriangularedButton = .create { + $0.applyDefaultStyle() + } + + let errorLabel = UILabel(style: .caption1Negative, textAlignment: .left, numberOfLines: 0) + private var warningView: InlineAlertView? + + override func setupLayout() { + super.setupLayout() + let title = UIView.hStack([ + slippageButton, + FlexibleSpaceView() + ]) + addArrangedSubview(title) + slippageButton.setContentHuggingPriority(.low, for: .horizontal) + addArrangedSubview(amountInput, spacingAfter: 8) + + amountInput.snp.makeConstraints { + $0.height.equalTo(48) + } + + errorLabel.isHidden = true + addArrangedSubview(errorLabel, spacingAfter: 8) + + addSubview(actionButton) + actionButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(UIConstants.horizontalInset) + make.bottom.equalTo(safeAreaLayoutGuide).inset(UIConstants.actionBottomInset) + make.height.equalTo(UIConstants.actionHeight) + } + } + + func set(error: String?) { + errorLabel.text = error + errorLabel.isHidden = error.isNilOrEmpty + amountInput.apply(style: error.isNilOrEmpty ? .normal : .error) + } + + func set(warning: String?) { + applyWarning( + on: &warningView, + after: errorLabel, + text: warning, + spacing: 16 + ) + } +} diff --git a/novawallet/Modules/Swaps/Slippage/SwapSlippageWireframe.swift b/novawallet/Modules/Swaps/Slippage/SwapSlippageWireframe.swift new file mode 100644 index 0000000000..b4d1598267 --- /dev/null +++ b/novawallet/Modules/Swaps/Slippage/SwapSlippageWireframe.swift @@ -0,0 +1,7 @@ +import Foundation + +final class SwapSlippageWireframe: SwapSlippageWireframeProtocol { + func close(from view: ControllerBackedProtocol?) { + view?.controller.navigationController?.popViewController(animated: true) + } +} diff --git a/novawallet/Modules/Swaps/Validation/SwapDataValidatorFactory.swift b/novawallet/Modules/Swaps/Validation/SwapDataValidatorFactory.swift new file mode 100644 index 0000000000..fc2ba127b8 --- /dev/null +++ b/novawallet/Modules/Swaps/Validation/SwapDataValidatorFactory.swift @@ -0,0 +1,331 @@ +import Foundation +import BigInt +import SoraFoundation + +typealias SwapRemoteValidatingClosure = (AssetConversion.QuoteArgs, @escaping SwapModel.QuoteValidateClosure) -> Void + +protocol SwapDataValidatorFactoryProtocol: BaseDataValidatingFactoryProtocol { + func hasSufficientBalance( + params: SwapModel, + swapMaxAction: @escaping () -> Void, + locale: Locale + ) -> DataValidating + + func canReceive(params: SwapModel, locale: Locale) -> DataValidating + + func noDustRemains( + params: SwapModel, + swapMaxAction: @escaping () -> Void, + locale: Locale + ) -> DataValidating + + func passesRealtimeQuoteValidation( + params: SwapModel, + remoteValidatingClosure: @escaping SwapRemoteValidatingClosure, + onQuoteUpdate: @escaping (AssetConversion.Quote) -> Void, + locale: Locale + ) -> DataValidating +} + +final class SwapDataValidatorFactory: SwapDataValidatorFactoryProtocol { + weak var view: (Localizable & ControllerBackedProtocol)? + + var basePresentable: BaseErrorPresentable { presentable } + + let presentable: SwapErrorPresentable + let balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol + + init( + presentable: SwapErrorPresentable, + balanceViewModelFactoryFacade: BalanceViewModelFactoryFacadeProtocol + ) { + self.presentable = presentable + self.balanceViewModelFactoryFacade = balanceViewModelFactoryFacade + } + + // swiftlint:disable:next function_body_length + func hasSufficientBalance( + params: SwapModel, + swapMaxAction: @escaping () -> Void, + locale: Locale + ) -> DataValidating { + let insufficientReason = params.checkBalanceSufficiency() + + return ErrorConditionViolation(onError: { [weak self] in + guard + let view = self?.view, + let reason = insufficientReason, + let viewModelFactory = self?.balanceViewModelFactoryFacade else { + return + } + + switch reason { + case .amountToHigh: + self?.presentable.presentAmountTooHigh(from: view, locale: locale) + case let .feeInNativeAsset(model): + let params = SwapDisplayError.InsufficientBalanceDueFeeNativeAsset( + available: viewModelFactory.amountFromValue( + targetAssetInfo: params.payChainAsset.assetDisplayInfo, + value: model.available + ).value(for: locale), + fee: viewModelFactory.amountFromValue( + targetAssetInfo: params.feeChainAsset.assetDisplayInfo, + value: model.fee + ).value(for: locale) + ) + + self?.presentable.presentInsufficientBalance( + from: view, + reason: .dueFeeNativeAsset(params), + action: swapMaxAction, + locale: locale + ) + case let .feeInPayAsset(model): + let utilityChainAsset = params.utilityChainAsset ?? params.feeChainAsset + + let params = SwapDisplayError.InsufficientBalanceDueFeePayAsset( + available: viewModelFactory.amountFromValue( + targetAssetInfo: params.payChainAsset.assetDisplayInfo, + value: model.available + ).value(for: locale), + fee: viewModelFactory.amountFromValue( + targetAssetInfo: params.feeChainAsset.assetDisplayInfo, + value: model.feeInPayAsset + ).value(for: locale), + minBalanceInPayAsset: viewModelFactory.amountFromValue( + targetAssetInfo: params.payChainAsset.assetDisplayInfo, + value: model.minBalanceInPayAsset + ).value(for: locale), + minBalanceInUtilityAsset: viewModelFactory.amountFromValue( + targetAssetInfo: utilityChainAsset.assetDisplayInfo, + value: model.minBalanceInNativeAsset + ).value(for: locale), + tokenSymbol: utilityChainAsset.asset.symbol + ) + + self?.presentable.presentInsufficientBalance( + from: view, + reason: .dueFeePayAsset(params), + action: swapMaxAction, + locale: locale + ) + case let .violatingConsumers(model): + let utilityChainAsset = params.utilityChainAsset ?? params.feeChainAsset + + let params = SwapDisplayError.InsufficientBalanceDueConsumers( + minBalance: viewModelFactory.amountFromValue( + targetAssetInfo: utilityChainAsset.assetDisplayInfo, + value: model.minBalance + ).value(for: locale), + fee: viewModelFactory.amountFromValue( + targetAssetInfo: params.feeChainAsset.assetDisplayInfo, + value: model.fee + ).value(for: locale) + ) + + self?.presentable.presentInsufficientBalance( + from: view, + reason: .dueConsumers(params), + action: swapMaxAction, + locale: locale + ) + } + + self?.presentable.presentNotEnoughLiquidity(from: view, locale: locale) + }, preservesCondition: { + insufficientReason == nil + }) + } + + func canReceive(params: SwapModel, locale: Locale) -> DataValidating { + let cantReceiveReason = params.checkCanReceive() + + return ErrorConditionViolation(onError: { [weak self] in + guard + let view = self?.view, + let reason = cantReceiveReason, + let viewModelFactory = self?.balanceViewModelFactoryFacade else { + return + } + + switch reason { + case let .existense(model): + self?.presentable.presentMinBalanceViolatedToReceive( + from: view, + minBalance: viewModelFactory.amountFromValue( + targetAssetInfo: params.receiveChainAsset.assetDisplayInfo, + value: model.minBalance + ).value(for: locale), + locale: locale + ) + case let .noProvider(model): + let utilityChainAsset = params.utilityChainAsset ?? params.feeChainAsset + + self?.presentable.presentNoProviderForNonSufficientToken( + from: view, + utilityMinBalance: viewModelFactory.amountFromValue( + targetAssetInfo: utilityChainAsset.assetDisplayInfo, + value: model.minBalance + ).value(for: locale), + token: params.receiveChainAsset.asset.symbol, + locale: locale + ) + } + + }, preservesCondition: { + cantReceiveReason == nil + }) + } + + // swiftlint:disable:next function_body_length + func noDustRemains( + params: SwapModel, + swapMaxAction: @escaping () -> Void, + locale: Locale + ) -> DataValidating { + let dustReason = params.checkDustAfterSwap() + + return WarningConditionViolation(onWarning: { [weak self] delegate in + guard + let view = self?.view, + let viewModelFactory = self?.balanceViewModelFactoryFacade, + let reason = dustReason else { + return + } + + let errorReason: SwapDisplayError.DustRemains + + switch reason { + case let .swap(model): + let params = SwapDisplayError.DustRemainsDueNativeSwap( + remaining: viewModelFactory.amountFromValue( + targetAssetInfo: params.payChainAsset.assetDisplayInfo, + value: model.dust + ).value(for: locale), + minBalance: viewModelFactory.amountFromValue( + targetAssetInfo: params.payChainAsset.assetDisplayInfo, + value: model.minBalance + ).value(for: locale) + ) + + errorReason = .dueNativeSwap(params) + case let .swapAndFee(model): + let utilityChainAsset = params.utilityChainAsset ?? params.feeChainAsset + + let params = SwapDisplayError.DustRemainsDueFeeSwap( + remaining: viewModelFactory.amountFromValue( + targetAssetInfo: params.payChainAsset.assetDisplayInfo, + value: model.dust + ).value(for: locale), + minBalanceOfPayAsset: viewModelFactory.amountFromValue( + targetAssetInfo: params.payChainAsset.assetDisplayInfo, + value: model.minBalance + ).value(for: locale), + fee: viewModelFactory.amountFromValue( + targetAssetInfo: params.feeChainAsset.assetDisplayInfo, + value: model.fee + ).value(for: locale), + minBalanceInPayAsset: viewModelFactory.amountFromValue( + targetAssetInfo: params.payChainAsset.assetDisplayInfo, + value: model.minBalanceInPayAsset + ).value(for: locale), + minBalanceInUtilityAsset: viewModelFactory.amountFromValue( + targetAssetInfo: utilityChainAsset.assetDisplayInfo, + value: model.minBalanceInNativeAsset + ).value(for: locale), + utilitySymbol: utilityChainAsset.asset.symbol + ) + + errorReason = .dueFeeSwap(params) + } + + self?.presentable.presentDustRemains( + from: view, + reason: errorReason, + swapMaxAction: swapMaxAction, + proceedAction: { + delegate.didCompleteWarningHandling() + }, + locale: locale + ) + + }, preservesCondition: { + dustReason == nil + }) + } + + // swiftlint:disable:next function_body_length + func passesRealtimeQuoteValidation( + params: SwapModel, + remoteValidatingClosure: @escaping SwapRemoteValidatingClosure, + onQuoteUpdate: @escaping (AssetConversion.Quote) -> Void, + locale: Locale + ) -> DataValidating { + var reason: SwapModel.InvalidQuoteReason? + + return AsyncWarningConditionViolation( + onWarning: { [weak self] delegate in + guard + let reason = reason, + let view = self?.view, + let viewModelFactory = self?.balanceViewModelFactoryFacade else { + return + } + + switch reason { + case let .rateChange(rateUpdate): + let oldRate = Decimal.rateFromSubstrate( + amount1: rateUpdate.oldQuote.amountIn, + amount2: rateUpdate.oldQuote.amountOut, + precision1: params.payChainAsset.assetDisplayInfo.assetPrecision, + precision2: params.receiveChainAsset.assetDisplayInfo.assetPrecision + ) ?? 0 + + let oldRateString = viewModelFactory.rateFromValue( + mainSymbol: params.payChainAsset.asset.symbol, + targetAssetInfo: params.receiveChainAsset.assetDisplayInfo, + value: oldRate + ).value(for: locale) + + let newRate = Decimal.rateFromSubstrate( + amount1: rateUpdate.newQuote.amountIn, + amount2: rateUpdate.newQuote.amountOut, + precision1: params.payChainAsset.assetDisplayInfo.assetPrecision, + precision2: params.receiveChainAsset.assetDisplayInfo.assetPrecision + ) ?? 0 + + let newRateString = viewModelFactory.rateFromValue( + mainSymbol: params.payChainAsset.asset.symbol, + targetAssetInfo: params.receiveChainAsset.assetDisplayInfo, + value: newRate + ).value(for: locale) + + self?.presentable.presentRateUpdated( + from: view, + oldRate: oldRateString, + newRate: newRateString, + onConfirm: { + onQuoteUpdate(rateUpdate.newQuote) + delegate.didCompleteAsyncHandling() + }, + locale: locale + ) + + case .noLiqudity: + self?.presentable.presentNotEnoughLiquidity( + from: view, + locale: locale + ) + } + }, + preservesCondition: { preservationCallback in + params.asyncCheckQuoteValidity(remoteValidatingClosure) { result in + let preserves = result == nil + reason = result + + preservationCallback(preserves) + } + } + ) + } +} diff --git a/novawallet/Modules/Swaps/Validation/SwapErrorPresentable.swift b/novawallet/Modules/Swaps/Validation/SwapErrorPresentable.swift new file mode 100644 index 0000000000..06f50b700a --- /dev/null +++ b/novawallet/Modules/Swaps/Validation/SwapErrorPresentable.swift @@ -0,0 +1,218 @@ +import Foundation + +protocol SwapErrorPresentable: BaseErrorPresentable { + func presentNotEnoughLiquidity(from view: ControllerBackedProtocol, locale: Locale?) + + func presentRateUpdated( + from view: ControllerBackedProtocol, + oldRate: String, + newRate: String, + onConfirm: @escaping () -> Void, + locale: Locale? + ) + + func presentInsufficientBalance( + from view: ControllerBackedProtocol?, + reason: SwapDisplayError.InsufficientBalance, + action: @escaping () -> Void, + locale: Locale + ) + + func presentDustRemains( + from view: ControllerBackedProtocol?, + reason: SwapDisplayError.DustRemains, + swapMaxAction: @escaping () -> Void, + proceedAction: @escaping () -> Void, + locale: Locale + ) + + func presentNoProviderForNonSufficientToken( + from view: ControllerBackedProtocol, + utilityMinBalance: String, + token: String, + locale: Locale + ) + + func presentMinBalanceViolatedToReceive( + from view: ControllerBackedProtocol, + minBalance: String, + locale: Locale + ) +} + +extension SwapErrorPresentable where Self: AlertPresentable & ErrorPresentable { + func presentNotEnoughLiquidity(from view: ControllerBackedProtocol, locale: Locale?) { + let title = R.string.localizable.swapsSetupErrorNotEnoughLiquidityTitle( + preferredLanguages: locale?.rLanguages) + let closeAction = R.string.localizable.commonClose( + preferredLanguages: locale?.rLanguages) + + present(message: nil, title: title, closeAction: closeAction, from: view) + } + + func presentRateUpdated( + from view: ControllerBackedProtocol, + oldRate: String, + newRate: String, + onConfirm: @escaping () -> Void, + locale: Locale? + ) { + let title = R.string.localizable.swapsErrorRateWasUpdatedTitle(preferredLanguages: locale?.rLanguages) + let message = R.string.localizable.swapsErrorRateWasUpdatedMessage( + oldRate, + newRate, + preferredLanguages: locale?.rLanguages + ) + + let cancelAction = AlertPresentableAction( + title: R.string.localizable.commonCancel(preferredLanguages: locale?.rLanguages) + ) + + let confirmAction = AlertPresentableAction( + title: R.string.localizable.commonConfirm(preferredLanguages: locale?.rLanguages), + handler: onConfirm + ) + + let viewModel = AlertPresentableViewModel( + title: title, + message: message, + actions: [cancelAction, confirmAction], + closeAction: nil + ) + + present(viewModel: viewModel, style: .alert, from: view) + } + + func presentNoProviderForNonSufficientToken( + from view: ControllerBackedProtocol, + utilityMinBalance: String, + token: String, + locale: Locale + ) { + let title = R.string.localizable.commonErrorGeneralTitle(preferredLanguages: locale.rLanguages) + let message = R.string.localizable.commonReceiveNotSufficientNativeAssetError( + utilityMinBalance, + token, + preferredLanguages: locale.rLanguages + ) + let closeAction = R.string.localizable.commonClose(preferredLanguages: locale.rLanguages) + + present(message: message, title: title, closeAction: closeAction, from: view) + } + + func presentMinBalanceViolatedToReceive( + from view: ControllerBackedProtocol, + minBalance: String, + locale: Locale + ) { + let title = R.string.localizable.commonErrorGeneralTitle(preferredLanguages: locale.rLanguages) + let message = R.string.localizable.commonReceiveAtLeastEdError( + minBalance, + preferredLanguages: locale.rLanguages + ) + let closeAction = R.string.localizable.commonClose(preferredLanguages: locale.rLanguages) + + present(message: message, title: title, closeAction: closeAction, from: view) + } + + func presentInsufficientBalance( + from view: ControllerBackedProtocol?, + reason: SwapDisplayError.InsufficientBalance, + action: @escaping () -> Void, + locale: Locale + ) { + let title = R.string.localizable.commonInsufficientBalance(preferredLanguages: locale.rLanguages) + let message: String + + switch reason { + case let .dueFeePayAsset(value): + message = R.string.localizable.swapsSetupErrorInsufficientBalanceFeeSwapMessage( + value.available, + value.fee, + value.minBalanceInPayAsset, + value.minBalanceInUtilityAsset, + value.tokenSymbol, + preferredLanguages: locale.rLanguages + ) + case let .dueFeeNativeAsset(value): + message = R.string.localizable.swapsSetupErrorInsufficientBalanceFeeNativeMessage( + value.available, + value.fee, + preferredLanguages: locale.rLanguages + ) + case let .dueConsumers(value): + message = R.string.localizable.swapsViolatingConsumersMessage( + value.minBalance, + value.fee, + preferredLanguages: locale.rLanguages + ) + } + + let cancelAction = AlertPresentableAction( + title: R.string.localizable.commonCancel(preferredLanguages: locale.rLanguages) + ) + + let swapAllAction = AlertPresentableAction( + title: R.string.localizable.commonSwapMax(preferredLanguages: locale.rLanguages), + handler: action + ) + + let viewModel = AlertPresentableViewModel( + title: title, + message: message, + actions: [cancelAction, swapAllAction], + closeAction: nil + ) + + present(viewModel: viewModel, style: .alert, from: view) + } + + func presentDustRemains( + from view: ControllerBackedProtocol?, + reason: SwapDisplayError.DustRemains, + swapMaxAction: @escaping () -> Void, + proceedAction: @escaping () -> Void, + locale: Locale + ) { + let title = R.string.localizable.commonDustRemainsTitle(preferredLanguages: locale.rLanguages) + let message: String + + switch reason { + case let .dueFeeSwap(value): + message = R.string.localizable.swapsDustRemainsFeePayAssetMessage( + value.minBalanceOfPayAsset, + value.fee, + value.minBalanceInPayAsset, + value.minBalanceInUtilityAsset, + value.utilitySymbol, + value.remaining, + preferredLanguages: locale.rLanguages + ) + case let .dueNativeSwap(value): + message = R.string.localizable.swapsDustRemainsFeeNativeAssetMessage( + value.minBalance, + value.remaining, + preferredLanguages: locale.rLanguages + ) + } + + let proceedAction = AlertPresentableAction( + title: R.string.localizable.commonProceed(preferredLanguages: locale.rLanguages), + handler: proceedAction + ) + + let swapAllAction = AlertPresentableAction( + title: R.string.localizable.commonSwapMax(preferredLanguages: locale.rLanguages), + handler: swapMaxAction + ) + + let viewModel = AlertPresentableViewModel( + title: title, + message: message, + actions: [proceedAction, swapAllAction], + closeAction: nil + ) + + present(viewModel: viewModel, style: .alert, from: view) + } +} diff --git a/novawallet/Modules/Swaps/Validation/SwapErrorPresentableParams.swift b/novawallet/Modules/Swaps/Validation/SwapErrorPresentableParams.swift new file mode 100644 index 0000000000..4865401240 --- /dev/null +++ b/novawallet/Modules/Swaps/Validation/SwapErrorPresentableParams.swift @@ -0,0 +1,44 @@ +enum SwapDisplayError { + struct InsufficientBalanceDueFeePayAsset { + let available: String + let fee: String + let minBalanceInPayAsset: String + let minBalanceInUtilityAsset: String + let tokenSymbol: String + } + + struct InsufficientBalanceDueFeeNativeAsset { + let available: String + let fee: String + } + + struct InsufficientBalanceDueConsumers { + let minBalance: String + let fee: String + } + + enum InsufficientBalance { + case dueFeePayAsset(InsufficientBalanceDueFeePayAsset) + case dueFeeNativeAsset(InsufficientBalanceDueFeeNativeAsset) + case dueConsumers(InsufficientBalanceDueConsumers) + } + + struct DustRemainsDueNativeSwap { + let remaining: String + let minBalance: String + } + + struct DustRemainsDueFeeSwap { + let remaining: String + let minBalanceOfPayAsset: String + let fee: String + let minBalanceInPayAsset: String + let minBalanceInUtilityAsset: String + let utilitySymbol: String + } + + enum DustRemains { + case dueNativeSwap(DustRemainsDueNativeSwap) + case dueFeeSwap(DustRemainsDueFeeSwap) + } +} diff --git a/novawallet/Modules/Swaps/Validation/SwapModel.swift b/novawallet/Modules/Swaps/Validation/SwapModel.swift new file mode 100644 index 0000000000..8c8bb3573f --- /dev/null +++ b/novawallet/Modules/Swaps/Validation/SwapModel.swift @@ -0,0 +1,285 @@ +import Foundation +import BigInt + +struct SwapModel { + struct InsufficientDueBalance { + let available: Decimal + } + + struct InsufficientDueNativeFee { + let available: Decimal + let fee: Decimal + } + + struct InsufficientDuePayAssetFee { + let available: Decimal + let feeInPayAsset: Decimal + let minBalanceInPayAsset: Decimal + let minBalanceInNativeAsset: Decimal + } + + struct InsufficientDueConsumers { + let minBalance: Decimal + let fee: Decimal + } + + enum InsufficientBalanceReason { + case amountToHigh(InsufficientDueBalance) + case feeInNativeAsset(InsufficientDueNativeFee) + case feeInPayAsset(InsufficientDuePayAssetFee) + case violatingConsumers(InsufficientDueConsumers) + } + + struct DustAfterSwap { + let dust: Decimal + let minBalance: Decimal + } + + struct DustAfterSwapAndFee { + let dust: Decimal + let minBalance: Decimal + let fee: Decimal + let minBalanceInPayAsset: Decimal + let minBalanceInNativeAsset: Decimal + } + + enum DustReason { + case swap(DustAfterSwap) + case swapAndFee(DustAfterSwapAndFee) + } + + struct CannotReceiveDueExistense { + let minBalance: Decimal + } + + struct CannotReceiveDueNoProviders { + let minBalance: Decimal + } + + enum CannotReceiveReason { + case existense(CannotReceiveDueExistense) + case noProvider(CannotReceiveDueNoProviders) + } + + struct InvalidQuoteDueRateChange { + let oldQuote: AssetConversion.Quote + let newQuote: AssetConversion.Quote + } + + enum InvalidQuoteReason { + case rateChange(InvalidQuoteDueRateChange) + case noLiqudity + } + + typealias QuoteValidateClosure = (Result) -> Void + + let payChainAsset: ChainAsset + let receiveChainAsset: ChainAsset + let feeChainAsset: ChainAsset + let spendingAmount: Decimal? + let payAssetBalance: AssetBalance? + let feeAssetBalance: AssetBalance? + let receiveAssetBalance: AssetBalance? + let utilityAssetBalance: AssetBalance? + let payAssetExistense: AssetBalanceExistence? + let receiveAssetExistense: AssetBalanceExistence? + let feeAssetExistense: AssetBalanceExistence? + let utilityAssetExistense: AssetBalanceExistence? + let feeModel: AssetConversion.FeeModel? + let quoteArgs: AssetConversion.QuoteArgs + let quote: AssetConversion.Quote? + let slippage: BigRational + let accountInfo: AccountInfo? + + var utilityChainAsset: ChainAsset? { + feeChainAsset.chain.utilityChainAsset() + } + + var spendingAmountInPlank: BigUInt? { + spendingAmount?.toSubstrateAmount(precision: payChainAsset.assetDisplayInfo.assetPrecision) + } + + var payAssetTotalBalanceAfterSwap: BigUInt { + let balance = payAssetBalance?.freeInPlank ?? 0 + let fee = isFeeInPayToken ? (feeModel?.totalFee.targetAmount ?? 0) : 0 + let spendingAmount = spendingAmountInPlank ?? 0 + + let totalSpending = spendingAmount + fee + + return balance > totalSpending ? balance - totalSpending : 0 + } + + var isFeeInPayToken: Bool { + payChainAsset.chainAssetId == feeChainAsset.chainAssetId + } + + func checkBalanceSufficiency() -> InsufficientBalanceReason? { + let balance = payAssetBalance?.transferable ?? 0 + let fee = isFeeInPayToken ? (feeModel?.totalFee.targetAmount ?? 0) : 0 + let swapAmount = spendingAmountInPlank ?? 0 + + let totalSpending = swapAmount + fee + + let isViolatingConsumers = !notViolatingConsumers + + guard balance < totalSpending || isViolatingConsumers else { + return nil + } + + if balance < swapAmount { + return .amountToHigh(.init(available: balance.decimal(precision: payChainAsset.asset.precision))) + } else if isViolatingConsumers { + let minBalance = utilityAssetExistense?.minBalance ?? 0 + let precision = (utilityChainAsset ?? feeChainAsset).asset.precision + let fee = feeModel?.totalFee.targetAmount ?? 0 + return .violatingConsumers( + .init( + minBalance: minBalance.decimal(precision: precision), + fee: fee.decimal(precision: precision) + ) + ) + } else if payChainAsset.isUtilityAsset { + let available = balance > fee ? balance - fee : 0 + + return .feeInNativeAsset( + .init( + available: available.decimal(precision: payChainAsset.asset.precision), + fee: fee.decimal(precision: feeChainAsset.asset.precision) + ) + ) + } else { + let available = balance > fee ? balance - fee : 0 + + if + isFeeInPayToken, + let addition = feeModel?.networkNativeFeeAddition, + let utilityAsset = feeChainAsset.chain.utilityAsset() { + return .feeInPayAsset( + .init( + available: available.decimal(precision: payChainAsset.asset.precision), + feeInPayAsset: fee.decimal(precision: feeChainAsset.asset.precision), + minBalanceInPayAsset: addition.targetAmount.decimal(precision: payChainAsset.asset.precision), + minBalanceInNativeAsset: addition.nativeAmount.decimal(precision: utilityAsset.precision) + ) + ) + } else { + return .feeInNativeAsset( + .init( + available: available.decimal(precision: payChainAsset.asset.precision), + fee: fee.decimal(precision: feeChainAsset.asset.precision) + ) + ) + } + } + } + + var accountWillBeKilled: Bool { + let balance: BigUInt + + if payChainAsset.isUtilityAsset { + balance = payAssetTotalBalanceAfterSwap + } else if feeChainAsset.isUtilityAsset { + let total = feeAssetBalance?.freeInPlank ?? 0 + let fee = feeModel?.totalFee.targetAmount ?? 0 + balance = total > fee ? total - fee : 0 + } else { + // if fee is paid in non native token then we will have at least ed + return false + } + + let minBalance = utilityAssetExistense?.minBalance ?? 0 + + return balance < minBalance + } + + var notViolatingConsumers: Bool { + guard accountWillBeKilled else { + return true + } + + return !(accountInfo?.hasConsumers ?? false) + } + + func checkCanReceive() -> CannotReceiveReason? { + let isSelfSufficient = receiveAssetExistense?.isSelfSufficient ?? false + let amountAfterSwap = (receiveAssetBalance?.freeInPlank ?? 0) + (quote?.amountOut ?? 0) + let feeInReceiveAsset = feeChainAsset.chainAssetId == receiveChainAsset.chainAssetId ? + (feeModel?.totalFee.targetAmount ?? 0) : 0 + let minBalance = receiveAssetExistense?.minBalance ?? 0 + + if amountAfterSwap < minBalance + feeInReceiveAsset { + return .existense( + .init(minBalance: minBalance.decimal(precision: receiveChainAsset.asset.precision)) + ) + } else if !isSelfSufficient, accountWillBeKilled { + let utilityMinBalance = utilityAssetExistense?.minBalance ?? 0 + let precision = (utilityChainAsset ?? feeChainAsset).asset.precision + return .noProvider( + .init(minBalance: utilityMinBalance.decimal(precision: precision)) + ) + } else { + return nil + } + } + + func checkDustAfterSwap() -> DustReason? { + let balance = payAssetTotalBalanceAfterSwap + let minBalance = payAssetExistense?.minBalance ?? 0 + + guard balance > 0, balance < minBalance else { + return nil + } + + let remaning = minBalance - balance + + if + isFeeInPayToken, !payChainAsset.isUtilityAsset, + let networkFee = feeModel?.networkFee, + let feeAdditions = feeModel?.networkNativeFeeAddition, + let utilityAsset = feeChainAsset.chain.utilityAsset() { + return .swapAndFee( + .init( + dust: remaning.decimal(precision: payChainAsset.asset.precision), + minBalance: minBalance.decimal(precision: payChainAsset.asset.precision), + fee: networkFee.targetAmount.decimal(precision: payChainAsset.asset.precision), + minBalanceInPayAsset: feeAdditions.targetAmount.decimal(precision: payChainAsset.asset.precision), + minBalanceInNativeAsset: feeAdditions.nativeAmount.decimal(precision: utilityAsset.precision) + ) + ) + } else { + return .swap( + .init( + dust: remaning.decimal(precision: payChainAsset.asset.precision), + minBalance: minBalance.decimal(precision: payChainAsset.asset.precision) + ) + ) + } + } + + func asyncCheckQuoteValidity( + _ newQuoteClosure: @escaping (AssetConversion.QuoteArgs, @escaping QuoteValidateClosure) -> Void, + completion: @escaping (InvalidQuoteReason?) -> Void + ) { + guard let currenQuote = quote else { + completion(.noLiqudity) + return + } + + newQuoteClosure(quoteArgs) { result in + switch result { + case let .success(newQuote): + if !currenQuote.matches( + other: newQuote, + slippage: slippage, + direction: quoteArgs.direction + ) { + completion(.rateChange(.init(oldQuote: currenQuote, newQuote: newQuote))) + } else { + completion(nil) + } + case .failure: + completion(.noLiqudity) + } + } + } +} diff --git a/novawallet/Modules/TransactionHistory/HistoryFilter/ViewModel/WalletHistoryFilterViewModel.swift b/novawallet/Modules/TransactionHistory/HistoryFilter/ViewModel/WalletHistoryFilterViewModel.swift index 6df45bf7f7..83023f212e 100644 --- a/novawallet/Modules/TransactionHistory/HistoryFilter/ViewModel/WalletHistoryFilterViewModel.swift +++ b/novawallet/Modules/TransactionHistory/HistoryFilter/ViewModel/WalletHistoryFilterViewModel.swift @@ -3,6 +3,7 @@ import SoraFoundation enum WalletHistoryFilterRow: Int, CaseIterable { case transfers + case swaps case rewardsAndSlashes case extrinsics @@ -21,6 +22,10 @@ enum WalletHistoryFilterRow: Int, CaseIterable { return LocalizableResource { locale in R.string.localizable.walletFiltersExtrinsics(preferredLanguages: locale.rLanguages) } + case .swaps: + return LocalizableResource { locale in + R.string.localizable.commonSwapTitle(preferredLanguages: locale.rLanguages) + } } } @@ -32,6 +37,8 @@ enum WalletHistoryFilterRow: Int, CaseIterable { return .rewardsAndSlashes case .extrinsics: return .extrinsics + case .swaps: + return .swaps } } } diff --git a/novawallet/Modules/TransactionHistory/Model/TransactionHistoryViewModelFactory.swift b/novawallet/Modules/TransactionHistory/Model/TransactionHistoryViewModelFactory.swift index 5d61372815..35cf41400d 100644 --- a/novawallet/Modules/TransactionHistory/Model/TransactionHistoryViewModelFactory.swift +++ b/novawallet/Modules/TransactionHistory/Model/TransactionHistoryViewModelFactory.swift @@ -27,7 +27,6 @@ final class TransactionHistoryViewModelFactory { let iconGenerator = PolkadotIconGenerator() let calendar = Calendar.current let dateFormatter: LocalizableResource - var chainFormat: ChainFormat { chainAsset.chain.chainFormat } init( @@ -92,7 +91,49 @@ final class TransactionHistoryViewModelFactory { subtitle: itemTitleWithSubtitle.subtitle, amount: balance.amount, amountDetails: amountDetails, - type: txType, + typeViewModel: .init(txType), + status: data.status, + imageViewModel: imageViewModel + ) + } + + private func createSwapItemFromData( + _ data: TransactionHistoryItem, + priceCalculator: TokenPriceCalculatorProtocol?, + locale: Locale, + txType: TransactionType + ) -> TransactionItemViewModel { + let assetIn = chainAsset.chain.assetOrNil(for: data.swap?.assetIdIn) + let assetOut = chainAsset.chain.assetOrNil(for: data.swap?.assetIdOut) + let isOutgoing = assetIn?.assetId == chainAsset.asset.assetId + let optAmountInPlank = isOutgoing ? data.swap?.amountIn : data.swap?.amountOut + let amountInPlank = optAmountInPlank.map { BigUInt($0) ?? 0 } ?? 0 + let precision = (isOutgoing ? assetIn?.precision : assetOut?.precision) ?? 0 + let amount = Decimal.fromSubstrateAmount( + amountInPlank, + precision: Int16(precision) + ) ?? .zero + let time = dateFormatter.value(for: locale) + .string(from: Date(timeIntervalSince1970: TimeInterval(data.timestamp))) + let balance = createBalance( + from: amount, + priceCalculator: priceCalculator, + timestamp: data.timestamp, + locale: locale + ) + let icon = R.image.iconSwap() + let imageViewModel = icon.map { StaticImageViewModel(image: $0) } + let amountDetails = amountDetails(price: balance.price, time: time, locale: locale) + let subtitle = [assetIn?.symbol, assetOut?.symbol].compactMap { $0 }.joined(separator: " → ") + + return .init( + identifier: data.identifier, + timestamp: data.timestamp, + title: R.string.localizable.commonSwapTitle(preferredLanguages: locale.rLanguages), + subtitle: subtitle, + amount: balance.amount, + amountDetails: amountDetails, + typeViewModel: .init(txType, isIncome: !isOutgoing), status: data.status, imageViewModel: imageViewModel ) @@ -222,7 +263,7 @@ final class TransactionHistoryViewModelFactory { subtitle: subtitle, amount: balance.amount, amountDetails: amountDetails, - type: txType, + typeViewModel: .init(txType), status: data.status, imageViewModel: imageViewModel ) @@ -265,7 +306,7 @@ final class TransactionHistoryViewModelFactory { subtitle: extrinsicTitleWithSubtitle.subtitle, amount: balance.amount, amountDetails: amountDetails, - type: txType, + typeViewModel: .init(txType), status: data.status, imageViewModel: imageViewModel ) @@ -330,6 +371,13 @@ extension TransactionHistoryViewModelFactory: TransactionHistoryViewModelFactory locale: locale, txType: transactionType ) + case .swap: + return createSwapItemFromData( + data, + priceCalculator: priceCalculator, + locale: locale, + txType: transactionType + ) case .reward, .slash: return createRewardOrSlashItemFromData( data, @@ -357,6 +405,10 @@ extension TransactionHistoryViewModelFactory: TransactionHistoryViewModelFactory extension TransactionHistoryItem { func type(for address: AccountAddress) -> TransactionType? { + if swap != nil { + return .swap + } + switch callPath { case .slash: return .slash diff --git a/novawallet/Modules/TransactionHistory/Model/TransactionSectionModel.swift b/novawallet/Modules/TransactionHistory/Model/TransactionSectionModel.swift index 02b672a946..b79e58f41e 100644 --- a/novawallet/Modules/TransactionHistory/Model/TransactionSectionModel.swift +++ b/novawallet/Modules/TransactionHistory/Model/TransactionSectionModel.swift @@ -14,13 +14,13 @@ struct TransactionItemViewModel: Hashable { lhs.subtitle == rhs.subtitle && lhs.amountDetails == rhs.amountDetails && lhs.amount == rhs.amount && - lhs.type == rhs.type && + lhs.typeViewModel.type == rhs.typeViewModel.type && lhs.status == rhs.status } func hash(into hasher: inout Hasher) { hasher.combine(identifier) - hasher.combine(type) + hasher.combine(typeViewModel.type) } let identifier: String @@ -29,7 +29,28 @@ struct TransactionItemViewModel: Hashable { let subtitle: String let amount: String let amountDetails: String - let type: TransactionType + let typeViewModel: TransactionTypeViewModel let status: TransactionHistoryItem.Status let imageViewModel: ImageViewModelProtocol? } + +struct TransactionTypeViewModel { + let type: TransactionType + let isIncome: Bool + + init(_ type: TransactionType, isIncome: Bool? = nil) { + self.type = type + self.isIncome = isIncome ?? TransactionTypeViewModel.incomeDefault(for: type) + } + + static func incomeDefault(for type: TransactionType) -> Bool { + switch type { + case .incoming, .reward, .poolReward: + return true + case .outgoing, .slash, .poolSlash, .extrinsic: + return false + case .swap: + return false + } + } +} diff --git a/novawallet/Modules/TransactionHistory/Service/AssetHistoryFactoryFacade.swift b/novawallet/Modules/TransactionHistory/Service/AssetHistoryFactoryFacade.swift index 933e1afb11..162b189453 100644 --- a/novawallet/Modules/TransactionHistory/Service/AssetHistoryFactoryFacade.swift +++ b/novawallet/Modules/TransactionHistory/Service/AssetHistoryFactoryFacade.swift @@ -29,15 +29,15 @@ final class AssetHistoryFacade { let assetMapper = CustomAssetMapper(type: asset.type, typeExtras: asset.typeExtras) let historyAssetId = try assetMapper.historyAssetId() - // we support only transfers for non utility assets - - let mappedFilter = asset.isUtility ? filter : .transfers + // we support only transfers and swaps for non utility assets + let mappedFilter = asset.isUtility ? filter : [.transfers, .swaps] return SubqueryHistoryOperationFactory( url: url, filter: mappedFilter, assetId: historyAssetId, - hasPoolStaking: asset.hasPoolStaking + hasPoolStaking: asset.hasPoolStaking, + hasSwaps: chainAsset.chain.hasSwaps ) } catch { return nil diff --git a/novawallet/Modules/TransactionHistory/Service/TransactionHistoryFetcherFactory.swift b/novawallet/Modules/TransactionHistory/Service/TransactionHistoryFetcherFactory.swift index 408d19acc9..a14749ba58 100644 --- a/novawallet/Modules/TransactionHistory/Service/TransactionHistoryFetcherFactory.swift +++ b/novawallet/Modules/TransactionHistory/Service/TransactionHistoryFetcherFactory.swift @@ -88,7 +88,25 @@ final class TransactionHistoryFetcherFactory { ) -> TransactionHistoryHybridFetcher { let localProvider = createLocalProvider(from: address, chainAsset: chainAsset, filter: .all) - let repository = repositoryFactory.createTxRepository() + let source = TransactionHistoryItemSource(assetTypeString: chainAsset.asset.type) + + let repository: AnyDataProviderRepository + + if chainAsset.isUtilityAsset { + repository = repositoryFactory.createUtilityAssetTxRepository( + for: address, + chainId: chainAsset.chain.chainId, + assetId: chainAsset.asset.assetId, + source: source + ) + } else { + repository = repositoryFactory.createCustomAssetTxRepository( + for: address, + chainId: chainAsset.chain.chainId, + assetId: chainAsset.asset.assetId, + source: source + ) + } return .init( address: address, diff --git a/novawallet/Modules/TransactionHistory/Service/WalletRemoteHistoryProtocols.swift b/novawallet/Modules/TransactionHistory/Service/WalletRemoteHistoryProtocols.swift index 459bbd804b..45cb4dc799 100644 --- a/novawallet/Modules/TransactionHistory/Service/WalletRemoteHistoryProtocols.swift +++ b/novawallet/Modules/TransactionHistory/Service/WalletRemoteHistoryProtocols.swift @@ -7,6 +7,7 @@ enum WalletRemoteHistorySourceLabel: Int, CaseIterable { case rewards case extrinsics case poolRewards + case swaps } protocol WalletRemoteHistoryItemProtocol { diff --git a/novawallet/Modules/TransactionHistory/TransactionHistoryViewFactory.swift b/novawallet/Modules/TransactionHistory/TransactionHistoryViewFactory.swift index 278c2d4bd3..24202b4bfc 100644 --- a/novawallet/Modules/TransactionHistory/TransactionHistoryViewFactory.swift +++ b/novawallet/Modules/TransactionHistory/TransactionHistoryViewFactory.swift @@ -4,7 +4,10 @@ import SoraFoundation import RobinHood struct TransactionHistoryViewFactory { - static func createView(chainAsset: ChainAsset) -> TransactionHistoryViewProtocol? { + static func createView( + chainAsset: ChainAsset, + operationState: AssetOperationState + ) -> TransactionHistoryViewProtocol? { guard let selectedMetaAccount = SelectedWalletSettings.shared.value, let accountId = selectedMetaAccount.fetch(for: chainAsset.chain.accountRequest())?.accountId, @@ -19,7 +22,10 @@ struct TransactionHistoryViewFactory { currencyManager: currencyManager ) - let wireframe = TransactionHistoryWireframe(chainAsset: chainAsset) + let wireframe = TransactionHistoryWireframe( + chainAsset: chainAsset, + operationState: operationState + ) let balanceViewModelFactory = BalanceViewModelFactory( targetAssetInfo: chainAsset.assetDisplayInfo, diff --git a/novawallet/Modules/TransactionHistory/TransactionHistoryWireframe.swift b/novawallet/Modules/TransactionHistory/TransactionHistoryWireframe.swift index f578bd7579..d5b42bdb65 100644 --- a/novawallet/Modules/TransactionHistory/TransactionHistoryWireframe.swift +++ b/novawallet/Modules/TransactionHistory/TransactionHistoryWireframe.swift @@ -2,9 +2,14 @@ import UIKit final class TransactionHistoryWireframe: TransactionHistoryWireframeProtocol { let chainAsset: ChainAsset + let operationState: AssetOperationState - init(chainAsset: ChainAsset) { + init( + chainAsset: ChainAsset, + operationState: AssetOperationState + ) { self.chainAsset = chainAsset + self.operationState = operationState } func showFilter( @@ -28,7 +33,8 @@ final class TransactionHistoryWireframe: TransactionHistoryWireframeProtocol { ) { guard let operationDetailsView = OperationDetailsViewFactory.createView( for: operation, - chainAsset: chainAsset + chainAsset: chainAsset, + operationState: operationState ) else { return } diff --git a/novawallet/Modules/TransactionHistory/View/HistoryItemTableViewCell.swift b/novawallet/Modules/TransactionHistory/View/HistoryItemTableViewCell.swift index ce1190be0b..a572e2e12b 100644 --- a/novawallet/Modules/TransactionHistory/View/HistoryItemTableViewCell.swift +++ b/novawallet/Modules/TransactionHistory/View/HistoryItemTableViewCell.swift @@ -168,16 +168,15 @@ extension HistoryItemTableViewCell { subtitleLabel.text = transactionModel.subtitle amountDetailsLabel.text = transactionModel.amountDetails - switch transactionModel.type { - case .incoming, .reward, .poolReward: + if transactionModel.typeViewModel.isIncome { amountLabel.text = "+ \(transactionModel.amount)" amountLabel.textColor = R.color.colorTextPositive()! - case .outgoing, .slash, .poolSlash, .extrinsic: + } else { amountLabel.text = "- \(transactionModel.amount)" amountLabel.textColor = R.color.colorTextPrimary()! } - switch transactionModel.type { + switch transactionModel.typeViewModel.type { case .incoming, .outgoing, .extrinsic: subtitleLabel.lineBreakMode = .byTruncatingMiddle @@ -186,7 +185,7 @@ extension HistoryItemTableViewCell { .offset(-Constants.titleSpacingForTransfer) } - case .slash, .reward, .poolReward, .poolSlash: + case .slash, .reward, .poolReward, .poolSlash, .swap: subtitleLabel.lineBreakMode = .byTruncatingTail subtitleLabel.snp.updateConstraints { make in diff --git a/novawallet/Modules/Transfer/BaseTransfer/OnChain/OnChainTransferInteractor.swift b/novawallet/Modules/Transfer/BaseTransfer/OnChain/OnChainTransferInteractor.swift index 545fd8baec..4bb0cf4733 100644 --- a/novawallet/Modules/Transfer/BaseTransfer/OnChain/OnChainTransferInteractor.swift +++ b/novawallet/Modules/Transfer/BaseTransfer/OnChain/OnChainTransferInteractor.swift @@ -282,11 +282,11 @@ class OnChainTransferInteractor: OnChainTransferBaseInteractor, RuntimeConstantF to builder: ExtrinsicBuilderProtocol, amount: OnChainTransferAmount, recepient: AccountId, - extras: StatemineAssetExtras + info: AssetsPalletStorageInfo ) throws -> (ExtrinsicBuilderProtocol, CallCodingPath?) { let call = callFactory.assetsTransfer( to: recepient, - extras: extras, + info: info, amount: amount.value ) @@ -327,12 +327,12 @@ class OnChainTransferInteractor: OnChainTransferBaseInteractor, RuntimeConstantF recepient: recepient, tokenStorageInfo: info ) - case let .statemine(extras): + case let .statemine(info): return try addingAssetsTransferCommand( to: builder, amount: amount, recepient: recepient, - extras: extras + info: info ) case let .native(info): return try addingNativeTransferCommand( diff --git a/novawallet/Modules/Transfer/Operation/AssetStorageInfoOperationFactory.swift b/novawallet/Modules/Transfer/Operation/AssetStorageInfoOperationFactory.swift index c2c3021250..45ba3055ac 100644 --- a/novawallet/Modules/Transfer/Operation/AssetStorageInfoOperationFactory.swift +++ b/novawallet/Modules/Transfer/Operation/AssetStorageInfoOperationFactory.swift @@ -16,6 +16,52 @@ protocol AssetStorageInfoOperationFactoryProtocol { ) -> CompoundOperationWrapper } +extension AssetStorageInfoOperationFactoryProtocol { + func createAssetBalanceExistenceOperation( + chainId: ChainModel.Id, + asset: AssetModel, + runtimeProvider: RuntimeCodingServiceProtocol, + operationQueue: OperationQueue + ) -> CompoundOperationWrapper { + let storageInfoWrapper = createStorageInfoWrapper( + from: asset, + runtimeProvider: runtimeProvider + ) + + let existenseBalanceOperation = OperationCombiningService( + operationManager: OperationManager(operationQueue: operationQueue) + ) { + let storageInfo = try storageInfoWrapper.targetOperation.extractNoCancellableResultData() + + let wrapper = self.createAssetBalanceExistenceOperation( + for: storageInfo, + chainId: chainId, + asset: asset + ) + + return [wrapper] + }.longrunOperation() + + existenseBalanceOperation.addDependency(storageInfoWrapper.targetOperation) + + let mappingOperation = ClosureOperation { + let models = try existenseBalanceOperation.extractNoCancellableResultData() + + guard let model = models.first else { + throw CommonError.dataCorruption + } + + return model + } + + mappingOperation.addDependency(existenseBalanceOperation) + + let dependencies = storageInfoWrapper.allOperations + [existenseBalanceOperation] + + return CompoundOperationWrapper(targetOperation: mappingOperation, dependencies: dependencies) + } +} + final class AssetStorageInfoOperationFactory { let chainRegistry: ChainRegistryProtocol let operationQueue: OperationQueue @@ -65,7 +111,16 @@ extension AssetStorageInfoOperationFactory: AssetStorageInfoOperationFactoryProt let fetchWrapper: CompoundOperationWrapper<[StorageResponse]> = requestFactory.queryItems( engine: connection, - keyParams: { [StringScaleMapper(value: extras.assetId)] }, + keyParams: { + let codingFactory = try codingFactoryOperation.extractNoCancellableResultData() + let param = try StatemineAssetSerializer.decode( + assetId: extras.assetId, + palletName: extras.palletName, + codingFactory: codingFactory + ) + + return [param] + }, factory: { try codingFactoryOperation.extractNoCancellableResultData() }, storagePath: assetsDetailsPath ) @@ -137,7 +192,7 @@ extension AssetStorageInfoOperationFactory: AssetStorageInfoOperationFactoryProt } return createNativeAssetExistenceOperation(for: runtimeService) - case let .statemine(extras): + case let .statemine(info): guard let runtimeService = chainRegistry.getRuntimeProvider(for: chainId) else { return .createWithError(ChainRegistryError.runtimeMetadaUnavailable) } @@ -147,7 +202,7 @@ extension AssetStorageInfoOperationFactory: AssetStorageInfoOperationFactoryProt } return createAssetsExistenceOperation( - for: extras, + for: .init(info: info), connection: connection, runtimeService: runtimeService ) diff --git a/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionInteractor.swift b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionInteractor.swift new file mode 100644 index 0000000000..4e19021525 --- /dev/null +++ b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionInteractor.swift @@ -0,0 +1,36 @@ +import UIKit + +final class TransferNetworkSelectionInteractor { + weak var presenter: TransferNetworkSelectionInteractorOutputProtocol? + + let assetListObservable: AssetListModelObservable + + init(assetListObservable: AssetListModelObservable) { + self.assetListObservable = assetListObservable + } + + private func provideModel() { + let balances = assetListObservable.state.value.balances.compactMapValues { balanceResult in + switch balanceResult { + case let .success(balance): + return balance + case let .failure(error): + return nil + } + } + + let prices = (try? assetListObservable.state.value.priceResult?.get()) ?? [:] + + presenter?.didReceive(balances: balances, prices: prices) + } +} + +extension TransferNetworkSelectionInteractor: TransferNetworkSelectionInteractorInputProtocol { + func setup() { + assetListObservable.addObserver(with: self, queue: .main) { [weak self] _, _ in + self?.provideModel() + } + + provideModel() + } +} diff --git a/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionPresenter.swift b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionPresenter.swift new file mode 100644 index 0000000000..86b388215e --- /dev/null +++ b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionPresenter.swift @@ -0,0 +1,70 @@ +import Foundation +import SoraFoundation + +final class TransferNetworkSelectionPresenter { + weak var view: TransferNetworkSelectionViewProtocol? + let interactor: TransferNetworkSelectionInteractorInputProtocol + let chainAssets: [ChainAsset] + let balanceViewModeFactoryFacade: BalanceViewModelFactoryFacadeProtocol + let networkViewModelFactory: NetworkViewModelFactoryProtocol + + private var balances: [ChainAssetId: AssetBalance] = [:] + private var prices: [ChainAssetId: PriceData] = [:] + + init( + chainAssets: [ChainAsset], + interactor: TransferNetworkSelectionInteractorInputProtocol, + balanceViewModeFactoryFacade: BalanceViewModelFactoryFacadeProtocol, + networkViewModelFactory: NetworkViewModelFactoryProtocol + ) { + self.chainAssets = chainAssets + self.interactor = interactor + self.balanceViewModeFactoryFacade = balanceViewModeFactoryFacade + self.networkViewModelFactory = networkViewModelFactory + } + + private func provideViewModel() { + let viewModels = chainAssets.map { chainAsset in + + let networkViewModel = networkViewModelFactory.createViewModel(from: chainAsset.chain) + let balanceViewModel: LocalizableResource? + + if let balance = balances[chainAsset.chainAssetId] { + let decimalBalance = balance.transferable.decimal(precision: chainAsset.asset.precision) + balanceViewModel = balanceViewModeFactoryFacade.balanceFromPrice( + targetAssetInfo: chainAsset.assetDisplayInfo, + amount: decimalBalance, + priceData: prices[chainAsset.chainAssetId] + ) + } else { + balanceViewModel = nil + } + + return LocalizableResource { locale in + TransferNetworkSelectionViewModel( + network: networkViewModel, + balance: balanceViewModel?.value(for: locale) + ) + } + } + + view?.didReceive(viewModels: viewModels) + } +} + +extension TransferNetworkSelectionPresenter: TransferNetworkSelectionPresenterProtocol { + func setup() { + provideViewModel() + + interactor.setup() + } +} + +extension TransferNetworkSelectionPresenter: TransferNetworkSelectionInteractorOutputProtocol { + func didReceive(balances: [ChainAssetId: AssetBalance], prices: [ChainAssetId: PriceData]) { + self.balances = balances + self.prices = prices + + provideViewModel() + } +} diff --git a/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionProtocols.swift b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionProtocols.swift new file mode 100644 index 0000000000..81f6a9cdbe --- /dev/null +++ b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionProtocols.swift @@ -0,0 +1,17 @@ +import SoraFoundation + +protocol TransferNetworkSelectionViewProtocol: ControllerBackedProtocol { + func didReceive(viewModels: [LocalizableResource]) +} + +protocol TransferNetworkSelectionPresenterProtocol: AnyObject { + func setup() +} + +protocol TransferNetworkSelectionInteractorInputProtocol: AnyObject { + func setup() +} + +protocol TransferNetworkSelectionInteractorOutputProtocol: AnyObject { + func didReceive(balances: [ChainAssetId: AssetBalance], prices: [ChainAssetId: PriceData]) +} diff --git a/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionViewController.swift b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionViewController.swift new file mode 100644 index 0000000000..16e03bde26 --- /dev/null +++ b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionViewController.swift @@ -0,0 +1,34 @@ +import UIKit +import SoraFoundation + +final class TransferNetworkSelectionViewController: ModalPickerViewController< + TransferNetworkSelectionCell, TransferNetworkSelectionViewModel +> { + let viewModelPresenter: TransferNetworkSelectionPresenterProtocol + + init(viewModelPresenter: TransferNetworkSelectionPresenterProtocol) { + self.viewModelPresenter = viewModelPresenter + + let nib = R.nib.modalPickerViewController + super.init(nibName: nib.name, bundle: nib.bundle) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + viewModelPresenter.setup() + } +} + +extension TransferNetworkSelectionViewController: TransferNetworkSelectionViewProtocol { + func didReceive(viewModels: [LocalizableResource]) { + self.viewModels = viewModels + + reload() + } +} diff --git a/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionViewFactory.swift b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionViewFactory.swift new file mode 100644 index 0000000000..bd2e945ed7 --- /dev/null +++ b/novawallet/Modules/Transfer/TransferNetworkSelection/TransferNetworkSelectionViewFactory.swift @@ -0,0 +1,75 @@ +import Foundation +import SoraUI +import SoraFoundation + +struct TransferNetworkSelectionViewFactory { + static func createView( + for selectionState: CrossChainOriginSelectionState, + assetListObservable: AssetListModelObservable, + delegate: ModalPickerViewControllerDelegate + ) -> TransferNetworkSelectionViewProtocol? { + guard let currencyManager = CurrencyManager.shared else { + return nil + } + + let interactor = TransferNetworkSelectionInteractor(assetListObservable: assetListObservable) + + let balanceViewModelFactory = BalanceViewModelFactoryFacade( + priceAssetInfoFactory: PriceAssetInfoFactory(currencyManager: currencyManager) + ) + + let presenter = TransferNetworkSelectionPresenter( + chainAssets: selectionState.availablePeerChainAssets, + interactor: interactor, + balanceViewModeFactoryFacade: balanceViewModelFactory, + networkViewModelFactory: NetworkViewModelFactory() + ) + + let viewController = TransferNetworkSelectionViewController(viewModelPresenter: presenter) + + presenter.view = viewController + interactor.presenter = presenter + + viewController.localizedTitle = LocalizableResource { locale in + R.string.localizable.commonFromNetwork(preferredLanguages: locale.rLanguages) + } + + viewController.modalPresentationStyle = .custom + viewController.separatorStyle = .none + viewController.headerBorderType = [] + viewController.actionType = .none + viewController.delegate = delegate + viewController.context = selectionState + viewController.isScrollEnabled = true + viewController.cellHeight = 52 + + let sectionTitle = LocalizableResource { locale in + R.string.localizable.commonCrossChain(preferredLanguages: locale.rLanguages) + } + + viewController.addSection(viewModels: [], title: sectionTitle) + + if let index = selectionState.availablePeerChainAssets.firstIndex( + where: { selectionState.selectedChainAssetId == $0.chainAssetId } + ) { + viewController.selectedIndex = index + } else { + viewController.selectedIndex = NSNotFound + } + + let factory = ModalSheetPresentationFactory(configuration: .nova) + viewController.modalTransitioningFactory = factory + + let itemsCount = selectionState.availablePeerChainAssets.count + let sectionsCount = 1 + let height = viewController.headerHeight + CGFloat(itemsCount) * viewController.cellHeight + + CGFloat(sectionsCount) * viewController.sectionHeaderHeight + + let maxHeight = ModalSheetPresentationConfiguration.maximumContentHeight + viewController.preferredContentSize = CGSize(width: 0.0, height: min(height, maxHeight)) + + viewController.localizationManager = LocalizationManager.shared + + return viewController + } +} diff --git a/novawallet/Modules/Transfer/TransferNetworkSelection/View/TransferNetworkSelectionCell.swift b/novawallet/Modules/Transfer/TransferNetworkSelection/View/TransferNetworkSelectionCell.swift new file mode 100644 index 0000000000..7b3a25249b --- /dev/null +++ b/novawallet/Modules/Transfer/TransferNetworkSelection/View/TransferNetworkSelectionCell.swift @@ -0,0 +1,58 @@ +import Foundation +import UIKit + +typealias TransferNetworkSelectionContentView = GenericTitleValueView< + AssetListChainView, + GenericPairValueView +> + +final class TransferNetworkSelectionCell: PlainBaseTableViewCell, + ModalPickerCellProtocol { + typealias Model = TransferNetworkSelectionViewModel + + var networkView: AssetListChainView { + contentDisplayView.titleView + } + + var selectorView: RadioSelectorView { + contentDisplayView.valueView.sView + } + + var balanceView: MultiValueView { + contentDisplayView.valueView.fView + } + + var checkmarked: Bool { + get { + selectorView.selected + } + + set { + selectorView.selected = newValue + } + } + + func bind(model: Model) { + networkView.bind(viewModel: model.network) + balanceView.bind(topValue: model.balance?.amount ?? "", bottomValue: model.balance?.price) + } + + override func setupStyle() { + super.setupStyle() + + backgroundColor = .clear + + contentDisplayView.valueView.setHorizontalAndSpacing(12) + contentDisplayView.valueView.stackView.alignment = .center + } + + override func setupLayout() { + super.setupLayout() + + let selectorSize = 2 * selectorView.outerRadius + + selectorView.snp.makeConstraints { make in + make.size.equalTo(selectorSize) + } + } +} diff --git a/novawallet/Modules/Transfer/TransferNetworkSelection/View/TransferNetworkSelectionViewModel.swift b/novawallet/Modules/Transfer/TransferNetworkSelection/View/TransferNetworkSelectionViewModel.swift new file mode 100644 index 0000000000..b847abc9fa --- /dev/null +++ b/novawallet/Modules/Transfer/TransferNetworkSelection/View/TransferNetworkSelectionViewModel.swift @@ -0,0 +1,6 @@ +import Foundation + +struct TransferNetworkSelectionViewModel { + let network: NetworkViewModel + let balance: BalanceViewModelProtocol? +} diff --git a/novawallet/Modules/Transfer/TransferSetup/Model/CrossChainDestinationSelectionState.swift b/novawallet/Modules/Transfer/TransferSetup/Model/CrossChainDestinationSelectionState.swift deleted file mode 100644 index 7e4552fa4e..0000000000 --- a/novawallet/Modules/Transfer/TransferSetup/Model/CrossChainDestinationSelectionState.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -class CrossChainDestinationSelectionState { - let originChain: ChainModel - let availableDestChains: [ChainModel] - let selectedChainId: ChainModel.Id - - init(originChain: ChainModel, availableDestChains: [ChainModel], selectedChainId: ChainModel.Id) { - self.originChain = originChain - self.availableDestChains = availableDestChains - self.selectedChainId = selectedChainId - } -} diff --git a/novawallet/Modules/Transfer/TransferSetup/Model/CrossChainSelectionState.swift b/novawallet/Modules/Transfer/TransferSetup/Model/CrossChainSelectionState.swift new file mode 100644 index 0000000000..a065c7f611 --- /dev/null +++ b/novawallet/Modules/Transfer/TransferSetup/Model/CrossChainSelectionState.swift @@ -0,0 +1,23 @@ +import Foundation + +class CrossChainDestinationSelectionState { + let chain: ChainModel + let availablePeerChains: [ChainModel] + let selectedChainId: ChainModel.Id + + init(chain: ChainModel, availablePeerChains: [ChainModel], selectedChainId: ChainModel.Id) { + self.chain = chain + self.availablePeerChains = availablePeerChains + self.selectedChainId = selectedChainId + } +} + +class CrossChainOriginSelectionState { + let availablePeerChainAssets: [ChainAsset] + let selectedChainAssetId: ChainAssetId + + init(availablePeerChainAssets: [ChainAsset], selectedChainAssetId: ChainAssetId) { + self.availablePeerChainAssets = availablePeerChainAssets + self.selectedChainAssetId = selectedChainAssetId + } +} diff --git a/novawallet/Modules/Transfer/TransferSetup/Model/TransferSetupPeer.swift b/novawallet/Modules/Transfer/TransferSetup/Model/TransferSetupPeer.swift new file mode 100644 index 0000000000..41c2ca903a --- /dev/null +++ b/novawallet/Modules/Transfer/TransferSetup/Model/TransferSetupPeer.swift @@ -0,0 +1,6 @@ +import Foundation + +enum TransferSetupPeer { + case origin + case destination +} diff --git a/novawallet/Modules/Transfer/TransferSetup/TransferSetupInteractor.swift b/novawallet/Modules/Transfer/TransferSetup/TransferSetupInteractor.swift index 31b358d812..cffa03ca4b 100644 --- a/novawallet/Modules/Transfer/TransferSetup/TransferSetupInteractor.swift +++ b/novawallet/Modules/Transfer/TransferSetup/TransferSetupInteractor.swift @@ -5,7 +5,8 @@ import SubstrateSdk final class TransferSetupInteractor: AccountFetching, AnyCancellableCleaning { weak var presenter: TransferSetupInteractorOutputProtocol? - let originChainAssetId: ChainAssetId + let chainAsset: ChainAsset + let whoChainAssetPeer: TransferSetupPeer let xcmTransfersSyncService: XcmTransfersSyncServiceProtocol let chainsStore: ChainsStoreProtocol let accountRepository: AnyDataProviderRepository @@ -13,17 +14,32 @@ final class TransferSetupInteractor: AccountFetching, AnyCancellableCleaning { let web3NamesService: Web3NameServiceProtocol? private var xcmTransfers: XcmTransfers? - private var destinationChainAsset: ChainAsset? + private var peerChainAsset: ChainAsset? + private var restrictedChainAssetPeers: [ChainAsset]? + + var destinationChainAsset: ChainAsset? { + switch whoChainAssetPeer { + case .origin: + return chainAsset + case .destination: + return peerChainAsset + } + } init( - originChainAssetId: ChainAssetId, + chainAsset: ChainAsset, + whoChainAssetPeer: TransferSetupPeer, + restrictedChainAssetPeers: [ChainAsset]?, xcmTransfersSyncService: XcmTransfersSyncServiceProtocol, chainsStore: ChainsStoreProtocol, accountRepository: AnyDataProviderRepository, web3NamesService: Web3NameServiceProtocol?, operationManager: OperationManagerProtocol ) { - self.originChainAssetId = originChainAssetId + self.chainAsset = chainAsset + self.whoChainAssetPeer = whoChainAssetPeer + peerChainAsset = restrictedChainAssetPeers?.first + self.restrictedChainAssetPeers = restrictedChainAssetPeers self.xcmTransfersSyncService = xcmTransfersSyncService self.chainsStore = chainsStore self.accountRepository = accountRepository @@ -57,14 +73,23 @@ final class TransferSetupInteractor: AccountFetching, AnyCancellableCleaning { private func provideAvailableTransfers() { guard let xcmTransfers = xcmTransfers else { - presenter?.didReceiveAvailableXcm(destinations: [], xcmTransfers: nil) + presenter?.didReceiveAvailableXcm(peerChainAssets: [], xcmTransfers: nil) return } - let transfers = xcmTransfers.transfers(from: originChainAssetId) + switch whoChainAssetPeer { + case .origin: + provideAvailableOrigins(for: xcmTransfers) + case .destination: + provideAvailableDestinations(for: xcmTransfers) + } + } + + private func provideAvailableDestinations(for xcmTransfers: XcmTransfers) { + let transfers = xcmTransfers.transfers(from: chainAsset.chainAssetId) guard !transfers.isEmpty else { - presenter?.didReceiveAvailableXcm(destinations: [], xcmTransfers: xcmTransfers) + presenter?.didReceiveAvailableXcm(peerChainAssets: [], xcmTransfers: xcmTransfers) return } @@ -79,10 +104,54 @@ final class TransferSetupInteractor: AccountFetching, AnyCancellableCleaning { return ChainAsset(chain: chain, asset: asset) } - presenter?.didReceiveAvailableXcm(destinations: destinations, xcmTransfers: xcmTransfers) + providePeerChainAssets(for: destinations, xcmTransfers: xcmTransfers) + } + + private func provideAvailableOrigins(for xcmTransfers: XcmTransfers) { + let transfers = xcmTransfers.transferChainAssets(to: chainAsset.chainAssetId) + + guard !transfers.isEmpty else { + presenter?.didReceiveAvailableXcm(peerChainAssets: [], xcmTransfers: xcmTransfers) + return + } + + let origins: [ChainAsset] = transfers.compactMap { chainAssetId in + guard + let chain = chainsStore.getChain(for: chainAssetId.chainId), + let asset = chain.asset(for: chainAssetId.assetId) + else { + return nil + } + + return ChainAsset(chain: chain, asset: asset) + } + + providePeerChainAssets(for: origins, xcmTransfers: xcmTransfers) } - private func fetchAccounts(for chain: ChainModel) { + private func providePeerChainAssets(for foundChainAssets: [ChainAsset], xcmTransfers: XcmTransfers) { + guard let restrictedChainAssetPeers = restrictedChainAssetPeers else { + presenter?.didReceiveAvailableXcm(peerChainAssets: foundChainAssets, xcmTransfers: xcmTransfers) + return + } + + let chainAssetIds = Set(foundChainAssets.map(\.chainAssetId)) + + let availableChainAssets = restrictedChainAssetPeers.filter { chainAssetIds.contains($0.chainAssetId) } + + presenter?.didReceiveAvailableXcm(peerChainAssets: availableChainAssets, xcmTransfers: xcmTransfers) + } + + private func fetchAccounts(for peerChain: ChainModel) { + let chain: ChainModel + + switch whoChainAssetPeer { + case .origin: + chain = chainAsset.chain + case .destination: + chain = peerChain + } + fetchAllMetaAccountChainResponses( for: chain.accountRequest(), repository: accountRepository, @@ -107,17 +176,17 @@ final class TransferSetupInteractor: AccountFetching, AnyCancellableCleaning { } extension TransferSetupInteractor: TransferSetupInteractorIntputProtocol { - func setup(destinationChainAsset: ChainAsset) { + func setup(peerChainAsset: ChainAsset) { setupChainsStore() setupXcmTransfersSyncService() - fetchAccounts(for: destinationChainAsset.chain) + fetchAccounts(for: peerChainAsset.chain) web3NamesService?.setup() - self.destinationChainAsset = destinationChainAsset + self.peerChainAsset = peerChainAsset } - func destinationChainAssetDidChanged(_ chainAsset: ChainAsset) { + func peerChainAssetDidChanged(_ chainAsset: ChainAsset) { fetchAccounts(for: chainAsset.chain) - destinationChainAsset = chainAsset + peerChainAsset = chainAsset } func search(web3Name: String) { diff --git a/novawallet/Modules/Transfer/TransferSetup/TransferSetupOriginSelectionWireframe.swift b/novawallet/Modules/Transfer/TransferSetup/TransferSetupOriginSelectionWireframe.swift new file mode 100644 index 0000000000..c02372c8ff --- /dev/null +++ b/novawallet/Modules/Transfer/TransferSetup/TransferSetupOriginSelectionWireframe.swift @@ -0,0 +1,26 @@ +import Foundation + +final class TransferSetupOriginSelectionWireframe: TransferSetupWireframe { + let assetListObservable: AssetListModelObservable + + init(assetListObservable: AssetListModelObservable) { + self.assetListObservable = assetListObservable + } + + override func showOriginChainSelection( + from view: TransferSetupViewProtocol?, + chainAsset _: ChainAsset, + selectionState: CrossChainOriginSelectionState, + delegate: ModalPickerViewControllerDelegate + ) { + guard let networkSelectionView = TransferNetworkSelectionViewFactory.createView( + for: selectionState, + assetListObservable: assetListObservable, + delegate: delegate + ) else { + return + } + + view?.controller.present(networkSelectionView.controller, animated: true, completion: nil) + } +} diff --git a/novawallet/Modules/Transfer/TransferSetup/TransferSetupPresenter.swift b/novawallet/Modules/Transfer/TransferSetup/TransferSetupPresenter.swift index 447303cd9e..5c990724d1 100644 --- a/novawallet/Modules/Transfer/TransferSetup/TransferSetupPresenter.swift +++ b/novawallet/Modules/Transfer/TransferSetup/TransferSetupPresenter.swift @@ -11,15 +11,16 @@ final class TransferSetupPresenter { let chainAssetViewModelFactory: ChainAssetViewModelFactoryProtocol let wallet: MetaAccountModel - let originChainAsset: ChainAsset + let chainAsset: ChainAsset + let whoChainAssetPeer: TransferSetupPeer let childPresenterFactory: TransferSetupPresenterFactoryProtocol let logger: LoggerProtocol let web3NameViewModelFactory: Web3NameViewModelFactoryProtocol var childPresenter: TransferSetupChildPresenterProtocol? - private(set) var destinationChainAsset: ChainAsset? - private(set) var availableDestinations: [ChainAsset]? + private(set) var peerChainAsset: ChainAsset? + private(set) var availablePeers: [ChainAsset]? private(set) var xcmTransfers: XcmTransfers? private(set) var recipientAddress: TransferSetupRecipientAccount? { didSet { @@ -35,19 +36,40 @@ final class TransferSetupPresenter { } private var metaChainAccountResponses: [MetaAccountChainResponse] = [] + + private var originChainAsset: ChainAsset? { + switch whoChainAssetPeer { + case .origin: + return peerChainAsset + case .destination: + return chainAsset + } + } + + private var destinationChainAsset: ChainAsset? { + switch whoChainAssetPeer { + case .origin: + return chainAsset + case .destination: + return peerChainAsset + } + } + private var destinationChainName: String { destinationChainAsset?.chain.name ?? "" } private var isOnChainTransfer: Bool { - destinationChainAsset == nil + peerChainAsset == nil } init( interactor: TransferSetupInteractorIntputProtocol, wireframe: TransferSetupWireframeProtocol, wallet: MetaAccountModel, - originChainAsset: ChainAsset, + chainAsset: ChainAsset, + whoChainAssetPeer: TransferSetupPeer, + chainAssetPeers: [ChainAsset]?, childPresenterFactory: TransferSetupPresenterFactoryProtocol, chainAssetViewModelFactory: ChainAssetViewModelFactoryProtocol, networkViewModelFactory: NetworkViewModelFactoryProtocol, @@ -57,7 +79,10 @@ final class TransferSetupPresenter { self.interactor = interactor self.wireframe = wireframe self.wallet = wallet - self.originChainAsset = originChainAsset + self.chainAsset = chainAsset + self.whoChainAssetPeer = whoChainAssetPeer + peerChainAsset = chainAssetPeers?.first + availablePeers = chainAssetPeers self.childPresenterFactory = childPresenterFactory self.chainAssetViewModelFactory = chainAssetViewModelFactory self.networkViewModelFactory = networkViewModelFactory @@ -73,7 +98,7 @@ final class TransferSetupPresenter { let initialState = childPresenter?.inputState ?? TransferSetupInputState() childPresenter = childPresenterFactory.createOnChainPresenter( - for: originChainAsset, + for: chainAsset, initialState: initialState, view: view ) @@ -86,6 +111,7 @@ final class TransferSetupPresenter { private func setupCrossChainChildPresenter() { guard let view = view, + let originChainAsset = originChainAsset, let destinationChainAsset = destinationChainAsset, let xcmTransfers = xcmTransfers else { return @@ -107,19 +133,33 @@ final class TransferSetupPresenter { } private func provideChainsViewModel() { - let originViewModel = chainAssetViewModelFactory.createViewModel(from: originChainAsset) + let mode: TransferNetworkContainerViewModel.Mode + let chainAssetViewModel = networkViewModelFactory.createViewModel(from: chainAsset.chain) + + let optPeerChainAsset: ChainAsset? + + if let availablePeers = availablePeers, !availablePeers.isEmpty { + optPeerChainAsset = peerChainAsset ?? chainAsset + } else { + optPeerChainAsset = peerChainAsset + } - let destinationViewModel: NetworkViewModel? + if let peerChainAsset = optPeerChainAsset { + let peerAssetViewModel = networkViewModelFactory.createViewModel(from: peerChainAsset.chain) - if let destinationChainAsset = destinationChainAsset { - destinationViewModel = networkViewModelFactory.createViewModel(from: destinationChainAsset.chain) - } else if let availableDestinations = availableDestinations, !availableDestinations.isEmpty { - destinationViewModel = networkViewModelFactory.createViewModel(from: originChainAsset.chain) + switch whoChainAssetPeer { + case .origin: + mode = .selectableOrigin(peerAssetViewModel, chainAssetViewModel) + case .destination: + mode = .selectableDestination(chainAssetViewModel, peerAssetViewModel) + } } else { - destinationViewModel = nil + mode = .onchain(chainAssetViewModel) } - view?.didReceiveOriginChain(originViewModel, destinationChain: destinationViewModel) + let viewModel = TransferNetworkContainerViewModel(assetSymbol: chainAsset.asset.symbol, mode: mode) + + view?.didReceiveSelection(viewModel: viewModel) } private func getYourWallets() -> [MetaAccountChainResponse] { @@ -141,7 +181,7 @@ final class TransferSetupPresenter { return } - let chain = destinationChainAsset?.chain ?? originChainAsset.chain + let chain = destinationChainAsset?.chain ?? chainAsset.chain view.didReceiveWeb3NameRecipient(viewModel: .cached(value: nil)) let viewModel = web3NameViewModelFactory.recipientListViewModel( recipients: recipients, @@ -162,7 +202,7 @@ final class TransferSetupPresenter { return } - let chain = destinationChainAsset?.chain ?? originChainAsset.chain + let chain = destinationChainAsset?.chain ?? chainAsset.chain if let account = recipient.normalizedAddress(for: chain.chainFormat) { let recipientViewModel = TransferSetupRecipientAccount.ExternalAccountValue( @@ -191,6 +231,63 @@ final class TransferSetupPresenter { break } } + + private func selectDestinationChain() { + let chain = chainAsset.chain + let selectedChainId = peerChainAsset?.chain.chainId ?? chain.chainId + + let availablePeerChains = availablePeers?.map(\.chain) ?? [] + + let selectionState = CrossChainDestinationSelectionState( + chain: chain, + availablePeerChains: availablePeerChains, + selectedChainId: selectedChainId + ) + + wireframe.showDestinationChainSelection( + from: view, + selectionState: selectionState, + delegate: self + ) + } + + private func selectOriginChain() { + let selectedChainAssetId = peerChainAsset?.chainAssetId ?? chainAsset.chainAssetId + + let peers = availablePeers ?? [] + + let selectionState = CrossChainOriginSelectionState( + availablePeerChainAssets: peers, + selectedChainAssetId: selectedChainAssetId + ) + + wireframe.showOriginChainSelection( + from: view, + chainAsset: chainAsset, + selectionState: selectionState, + delegate: self + ) + } + + private func handleNewChainAssetSelection(_ newPeerChainAsset: ChainAsset?) { + if recipientAddress?.isExternal == true { + recipientAddress = nil + childPresenter?.updateRecepient(partialAddress: "") + } + + peerChainAsset = newPeerChainAsset + + provideChainsViewModel() + updateYourWalletsButton() + + if let peerChainAsset = peerChainAsset { + setupCrossChainChildPresenter() + interactor.peerChainAssetDidChanged(peerChainAsset) + } else { + setupOnChainChildPresenter() + interactor.peerChainAssetDidChanged(chainAsset) + } + } } extension TransferSetupPresenter: TransferSetupPresenterProtocol { @@ -198,7 +295,7 @@ extension TransferSetupPresenter: TransferSetupPresenterProtocol { provideChainsViewModel() childPresenter?.setup() - interactor.setup(destinationChainAsset: destinationChainAsset ?? originChainAsset) + interactor.setup(peerChainAsset: peerChainAsset ?? chainAsset) } func updateRecepient(partialAddress: String) { @@ -247,24 +344,13 @@ extension TransferSetupPresenter: TransferSetupPresenterProtocol { } } - func changeDestinationChain() { - let originChain = originChainAsset.chain - let selectedChainId = destinationChainAsset?.chain.chainId ?? originChain.chainId - - let availableDestinationChains = availableDestinations?.map(\.chain) ?? [] - - let selectionState = CrossChainDestinationSelectionState( - originChain: originChain, - availableDestChains: availableDestinationChains, - selectedChainId: selectedChainId - ) - - wireframe.showDestinationChainSelection( - from: view, - selectionState: selectionState, - delegate: self, - context: selectionState - ) + func selectChain() { + switch whoChainAssetPeer { + case .destination: + selectDestinationChain() + case .origin: + selectOriginChain() + } } func didTapOnYourWallets() { @@ -296,7 +382,7 @@ extension TransferSetupPresenter: TransferSetupPresenterProtocol { return } - let chain = destinationChainAsset?.chain ?? originChainAsset.chain + let chain = destinationChainAsset?.chain ?? chainAsset.chain wireframe.presentAccountOptions( from: view, @@ -308,18 +394,18 @@ extension TransferSetupPresenter: TransferSetupPresenterProtocol { } extension TransferSetupPresenter: TransferSetupInteractorOutputProtocol { - func didReceiveAvailableXcm(destinations: [ChainAsset], xcmTransfers: XcmTransfers?) { - let symbol = originChainAsset.asset.symbol - let chainName = originChainAsset.chain.name - logger.debug("(\(chainName) \(symbol) Available destinations: \(destinations.count)") + func didReceiveAvailableXcm(peerChainAssets: [ChainAsset], xcmTransfers: XcmTransfers?) { + let symbol = chainAsset.asset.symbol + let chainName = chainAsset.chain.name + logger.debug("(\(chainName) \(symbol) Available peers: \(peerChainAssets.count)") - availableDestinations = destinations + availablePeers = peerChainAssets self.xcmTransfers = xcmTransfers if - let destinationChainAsset = destinationChainAsset, - !destinations.contains(where: { $0.chainAssetId == destinationChainAsset.chainAssetId }) { - self.destinationChainAsset = nil + let peerChainAsset = peerChainAsset, + !peerChainAssets.contains(where: { $0.chainAssetId == peerChainAsset.chainAssetId }) { + self.peerChainAsset = nil setupOnChainChildPresenter() } @@ -363,50 +449,34 @@ extension TransferSetupPresenter: TransferSetupInteractorOutputProtocol { extension TransferSetupPresenter: ModalPickerViewControllerDelegate { func modalPickerDidSelectModel(at index: Int, section: Int, context: AnyObject?) { - view?.didCompleteDestinationSelection() - - guard let selectionState = context as? CrossChainDestinationSelectionState else { - return - } - - if recipientAddress?.isExternal == true { - recipientAddress = nil - childPresenter?.updateRecepient(partialAddress: "") - } - - if section == 0 { - destinationChainAsset = nil - } else { - let selectedChain = selectionState.availableDestChains[index] - let selectedChainId = selectedChain.chainId + view?.didCompleteChainSelection() - destinationChainAsset = availableDestinations?.first { $0.chain.chainId == selectedChainId } - } + if let selectionState = context as? CrossChainDestinationSelectionState { + if section == 0 { + handleNewChainAssetSelection(nil) + } else { + let selectedChain = selectionState.availablePeerChains[index] + let selectedChainId = selectedChain.chainId - provideChainsViewModel() - updateYourWalletsButton() + let newPeerChainAsset = availablePeers?.first { $0.chain.chainId == selectedChainId } - if let destinationChainAsset = destinationChainAsset { - setupCrossChainChildPresenter() - interactor.destinationChainAssetDidChanged(destinationChainAsset) - } else { - setupOnChainChildPresenter() - interactor.destinationChainAssetDidChanged(originChainAsset) + handleNewChainAssetSelection(newPeerChainAsset) + } } } func modalPickerDidSelectModelAtIndex(_ index: Int, context: AnyObject?) { - guard let selectionState = context as? Web3NameAddressesSelectionState else { - return + if let selectionState = context as? Web3NameAddressesSelectionState { + let selectedAccount = selectionState.accounts[index] + provideWeb3NameRecipientViewModel(selectedAccount, name: selectionState.name) + } else if let selectionState = context as? CrossChainOriginSelectionState { + handleNewChainAssetSelection(selectionState.availablePeerChainAssets[index]) } - - let selectedAccount = selectionState.accounts[index] - provideWeb3NameRecipientViewModel(selectedAccount, name: selectionState.name) } func modalPickerDidCancel(context: AnyObject?) { - if context is CrossChainDestinationSelectionState { - view?.didCompleteDestinationSelection() + if context is CrossChainDestinationSelectionState || context is CrossChainOriginSelectionState { + view?.didCompleteChainSelection() } else if context is Web3NameAddressesSelectionState { view?.didReceiveRecipientInputState(focused: true, empty: nil) } diff --git a/novawallet/Modules/Transfer/TransferSetup/TransferSetupProtocols.swift b/novawallet/Modules/Transfer/TransferSetup/TransferSetupProtocols.swift index 6f046435ca..b1864fb0ff 100644 --- a/novawallet/Modules/Transfer/TransferSetup/TransferSetupProtocols.swift +++ b/novawallet/Modules/Transfer/TransferSetup/TransferSetupProtocols.swift @@ -15,8 +15,8 @@ protocol TransferSetupChildViewProtocol: ControllerBackedProtocol, Localizable { } protocol TransferSetupViewProtocol: TransferSetupChildViewProtocol { - func didReceiveOriginChain(_ originChain: ChainAssetViewModel, destinationChain: NetworkViewModel?) - func didCompleteDestinationSelection() + func didReceiveSelection(viewModel: TransferNetworkContainerViewModel) + func didCompleteChainSelection() func didSwitchCrossChain() func didSwitchOnChain() func changeYourWalletsViewState(_ state: YourWalletsControl.State) @@ -39,7 +39,7 @@ protocol TransferSetupChildPresenterProtocol: TransferSetupCommonPresenterProtoc } protocol TransferSetupPresenterProtocol: TransferSetupCommonPresenterProtocol { - func changeDestinationChain() + func selectChain() func scanRecepientCode() func applyMyselfRecepient() func didTapOnYourWallets() @@ -48,13 +48,13 @@ protocol TransferSetupPresenterProtocol: TransferSetupCommonPresenterProtocol { } protocol TransferSetupInteractorIntputProtocol: AnyObject { - func setup(destinationChainAsset: ChainAsset) - func destinationChainAssetDidChanged(_ chainAsset: ChainAsset) + func setup(peerChainAsset: ChainAsset) + func peerChainAssetDidChanged(_ chainAsset: ChainAsset) func search(web3Name: String) } protocol TransferSetupInteractorOutputProtocol: AnyObject { - func didReceiveAvailableXcm(destinations: [ChainAsset], xcmTransfers: XcmTransfers?) + func didReceiveAvailableXcm(peerChainAssets: [ChainAsset], xcmTransfers: XcmTransfers?) func didReceive(error: Error) func didReceive(metaChainAccountResponses: [MetaAccountChainResponse]) func didReceive(recipients: [Web3TransferRecipient], for name: String) @@ -65,8 +65,14 @@ protocol TransferSetupWireframeProtocol: AlertPresentable, ErrorPresentable, Add func showDestinationChainSelection( from view: TransferSetupViewProtocol?, selectionState: CrossChainDestinationSelectionState, - delegate: ModalPickerViewControllerDelegate, - context: AnyObject? + delegate: ModalPickerViewControllerDelegate + ) + + func showOriginChainSelection( + from view: TransferSetupViewProtocol?, + chainAsset: ChainAsset, + selectionState: CrossChainOriginSelectionState, + delegate: ModalPickerViewControllerDelegate ) func showRecepientScan(from view: TransferSetupViewProtocol?, delegate: AddressScanDelegate) diff --git a/novawallet/Modules/Transfer/TransferSetup/TransferSetupViewController.swift b/novawallet/Modules/Transfer/TransferSetup/TransferSetupViewController.swift index 442d8f9624..600ae54738 100644 --- a/novawallet/Modules/Transfer/TransferSetup/TransferSetupViewController.swift +++ b/novawallet/Modules/Transfer/TransferSetup/TransferSetupViewController.swift @@ -192,8 +192,8 @@ final class TransferSetupViewController: UIViewController, ViewHolder { presenter.proceed() } - @objc func actionChangeDestination() { - presenter.changeDestinationChain() + @objc func actionChangeChain() { + presenter.selectChain() } @objc func actionSendMyself() { @@ -219,25 +219,18 @@ extension TransferSetupViewController: TransferSetupViewProtocol { rootView.yourWalletsControl.apply(state: state) } - func didReceiveOriginChain(_ originChain: ChainAssetViewModel, destinationChain: NetworkViewModel?) { - let assetViewModel = originChain.assetViewModel - let viewModel = TransferNetworkContainerViewModel( - assetSymbol: assetViewModel.symbol, - originNetwork: originChain.networkViewModel, - destNetwork: destinationChain - ) - + func didReceiveSelection(viewModel: TransferNetworkContainerViewModel) { rootView.networkContainerView.bind(viewModel: viewModel) - rootView.networkContainerView.destinationNetworkView?.actionControl.addTarget( + rootView.networkContainerView.selectableNetworkView?.actionControl.addTarget( self, - action: #selector(actionChangeDestination), + action: #selector(actionChangeChain), for: .touchUpInside ) } - func didCompleteDestinationSelection() { - rootView.networkContainerView.destinationNetworkView?.actionControl.deactivate(animated: true) + func didCompleteChainSelection() { + rootView.networkContainerView.selectableNetworkView?.actionControl.deactivate(animated: true) } func didReceiveInputChainAsset(viewModel: ChainAssetViewModel) { diff --git a/novawallet/Modules/Transfer/TransferSetup/TransferSetupViewFactory.swift b/novawallet/Modules/Transfer/TransferSetup/TransferSetupViewFactory.swift index 76f641f87a..a7a781ab46 100644 --- a/novawallet/Modules/Transfer/TransferSetup/TransferSetupViewFactory.swift +++ b/novawallet/Modules/Transfer/TransferSetup/TransferSetupViewFactory.swift @@ -1,30 +1,79 @@ import Foundation import SoraFoundation - import RobinHood -struct TransferSetupViewFactory { +struct TransferSetupViewParams { + let chainAsset: ChainAsset + let whoChainAssetPeer: TransferSetupPeer + let chainAssetPeers: [ChainAsset]? + let recepient: DisplayAddress? + let xcmTransfers: XcmTransfers? +} + +enum TransferSetupViewFactory { static func createView( from chainAsset: ChainAsset, recepient: DisplayAddress?, transferCompletion: TransferCompletionClosure? = nil + ) -> TransferSetupViewProtocol? { + createView( + from: .init( + chainAsset: chainAsset, + whoChainAssetPeer: .destination, + chainAssetPeers: nil, + recepient: recepient, + xcmTransfers: nil + ), + wireframe: TransferSetupWireframe(), + transferCompletion: transferCompletion + ) + } + + static func createCrosschainView( + from origins: [ChainAsset], + to destination: ChainAsset, + xcmTransfers: XcmTransfers?, + assetListObservable: AssetListModelObservable, + transferCompletion: TransferCompletionClosure? = nil ) -> TransferSetupViewProtocol? { guard let wallet = SelectedWalletSettings.shared.value else { return nil } - guard let interactor = createInteractor(for: chainAsset) else { + let recepient = try? wallet.fetch(for: destination.chain.accountRequest())?.toDisplayAddress() + + return createView( + from: .init( + chainAsset: destination, + whoChainAssetPeer: .origin, + chainAssetPeers: origins, + recepient: recepient, + xcmTransfers: xcmTransfers + ), + wireframe: TransferSetupOriginSelectionWireframe(assetListObservable: assetListObservable), + transferCompletion: transferCompletion + ) + } + + static func createView( + from params: TransferSetupViewParams, + wireframe: TransferSetupWireframeProtocol, + transferCompletion: TransferCompletionClosure? + ) -> TransferSetupViewProtocol? { + guard let wallet = SelectedWalletSettings.shared.value else { return nil } - let initPresenterState = TransferSetupInputState(recepient: recepient?.address, amount: nil) + guard let interactor = createInteractor(for: params) else { + return nil + } + + let initPresenterState = TransferSetupInputState(recepient: params.recepient?.address, amount: nil) let presenterFactory = createPresenterFactory(for: wallet, transferCompletion: transferCompletion) let localizationManager = LocalizationManager.shared - let wireframe = TransferSetupWireframe() - let networkViewModelFactory = NetworkViewModelFactory() let chainAssetViewModelFactory = ChainAssetViewModelFactory(networkViewModelFactory: networkViewModelFactory) let viewModelFactory = Web3NameViewModelFactory( @@ -35,7 +84,9 @@ struct TransferSetupViewFactory { interactor: interactor, wireframe: wireframe, wallet: wallet, - originChainAsset: chainAsset, + chainAsset: params.chainAsset, + whoChainAssetPeer: params.whoChainAssetPeer, + chainAssetPeers: params.chainAssetPeers, childPresenterFactory: presenterFactory, chainAssetViewModelFactory: chainAssetViewModelFactory, networkViewModelFactory: networkViewModelFactory, @@ -48,11 +99,36 @@ struct TransferSetupViewFactory { localizationManager: localizationManager ) - presenter.childPresenter = presenterFactory.createOnChainPresenter( - for: chainAsset, - initialState: initPresenterState, - view: view - ) + if + let peerChainAsset = params.chainAssetPeers?.first, + peerChainAsset.chainAssetId != params.chainAsset.chainAssetId, + let xcmTransfers = params.xcmTransfers { + let origin: ChainAsset + let destination: ChainAsset + + switch params.whoChainAssetPeer { + case .origin: + origin = peerChainAsset + destination = params.chainAsset + case .destination: + origin = params.chainAsset + destination = peerChainAsset + } + + presenter.childPresenter = presenterFactory.createCrossChainPresenter( + for: origin, + destinationChainAsset: destination, + xcmTransfers: xcmTransfers, + initialState: initPresenterState, + view: view + ) + } else { + presenter.childPresenter = presenterFactory.createOnChainPresenter( + for: params.chainAsset, + initialState: initPresenterState, + view: view + ) + } presenter.view = view interactor.presenter = presenter @@ -74,9 +150,7 @@ struct TransferSetupViewFactory { ) } - private static func createInteractor( - for chainAsset: ChainAsset - ) -> TransferSetupInteractor? { + private static func createInteractor(for params: TransferSetupViewParams) -> TransferSetupInteractor? { let chainRegistry = ChainRegistryFacade.sharedRegistry let syncService = XcmTransfersSyncService( @@ -93,7 +167,9 @@ struct TransferSetupViewFactory { let web3NameService = createWeb3NameService() return TransferSetupInteractor( - originChainAssetId: chainAsset.chainAssetId, + chainAsset: params.chainAsset, + whoChainAssetPeer: params.whoChainAssetPeer, + restrictedChainAssetPeers: params.chainAssetPeers, xcmTransfersSyncService: syncService, chainsStore: chainsStore, accountRepository: accountRepository, diff --git a/novawallet/Modules/Transfer/TransferSetup/TransferSetupWireframe.swift b/novawallet/Modules/Transfer/TransferSetup/TransferSetupWireframe.swift index dd96082f46..14271f7eb3 100644 --- a/novawallet/Modules/Transfer/TransferSetup/TransferSetupWireframe.swift +++ b/novawallet/Modules/Transfer/TransferSetup/TransferSetupWireframe.swift @@ -2,22 +2,38 @@ import Foundation import SoraFoundation import SoraUI -final class TransferSetupWireframe: TransferSetupWireframeProtocol { +class TransferSetupWireframe: TransferSetupWireframeProtocol { func showDestinationChainSelection( from view: TransferSetupViewProtocol?, selectionState: CrossChainDestinationSelectionState, - delegate: ModalPickerViewControllerDelegate, - context: AnyObject? + delegate: ModalPickerViewControllerDelegate ) { - guard let viewController = ModalNetworksFactory.createNetworkSelectionList( + showChainSelection( + from: view, selectionState: selectionState, delegate: delegate, - context: context - ) else { - return - } + context: selectionState + ) + } - view?.controller.present(viewController, animated: true, completion: nil) + func showOriginChainSelection( + from view: TransferSetupViewProtocol?, + chainAsset: ChainAsset, + selectionState: CrossChainOriginSelectionState, + delegate: ModalPickerViewControllerDelegate + ) { + let mappedState = CrossChainDestinationSelectionState( + chain: chainAsset.chain, + availablePeerChains: selectionState.availablePeerChainAssets.map(\.chain), + selectedChainId: selectionState.selectedChainAssetId.chainId + ) + + showChainSelection( + from: view, + selectionState: mappedState, + delegate: delegate, + context: selectionState + ) } func showRecepientScan(from view: TransferSetupViewProtocol?, delegate: AddressScanDelegate) { @@ -68,4 +84,21 @@ final class TransferSetupWireframe: TransferSetupWireframeProtocol { func checkDismissing(view: TransferSetupViewProtocol?) -> Bool { view?.controller.navigationController?.isBeingDismissed ?? true } + + private func showChainSelection( + from view: TransferSetupViewProtocol?, + selectionState: CrossChainDestinationSelectionState, + delegate: ModalPickerViewControllerDelegate, + context: AnyObject? + ) { + guard let viewController = ModalNetworksFactory.createNetworkSelectionList( + selectionState: selectionState, + delegate: delegate, + context: context + ) else { + return + } + + view?.controller.present(viewController, animated: true, completion: nil) + } } diff --git a/novawallet/Modules/Transfer/View/TransferNetworkContainerView.swift b/novawallet/Modules/Transfer/View/TransferNetworkContainerView.swift index 606258bce8..50aa1abb9a 100644 --- a/novawallet/Modules/Transfer/View/TransferNetworkContainerView.swift +++ b/novawallet/Modules/Transfer/View/TransferNetworkContainerView.swift @@ -9,8 +9,8 @@ final class TransferNetworkContainerView: UIView { var horizontalSpacing: CGFloat = 6.0 var verticalSpacing: CGFloat = 7.0 - let originNetworkView = AssetListChainView() - private(set) var destinationNetworkView: AssetListChainControlView? + let staticNetworkView = AssetListChainView() + private(set) var selectableNetworkView: AssetListChainControlView? var locale = Locale.current { didSet { @@ -24,14 +24,14 @@ final class TransferNetworkContainerView: UIView { private var viewModel: TransferNetworkContainerViewModel? - private var isCrossChain: Bool { viewModel?.destNetwork != nil } + private var isCrossChain: Bool { viewModel?.isCrosschain ?? false } override init(frame: CGRect) { super.init(frame: frame) addSubview(tokenLabel) addSubview(fromLabel) - addSubview(originNetworkView) + addSubview(staticNetworkView) } @available(*, unavailable) @@ -72,29 +72,36 @@ final class TransferNetworkContainerView: UIView { func bind(viewModel: TransferNetworkContainerViewModel) { self.viewModel = viewModel - originNetworkView.bind(viewModel: viewModel.originNetwork) + switch viewModel.mode { + case let .onchain(networkViewModel): + setupOnChain() - if let destViewModel = viewModel.destNetwork { + staticNetworkView.bind(viewModel: networkViewModel) + case let .selectableOrigin(origin, destination): setupCrossChain() - destinationNetworkView?.bind(viewModel: destViewModel) - } else { - setupOnChain() + staticNetworkView.bind(viewModel: destination) + selectableNetworkView?.bind(viewModel: origin) + case let .selectableDestination(origin, destination): + setupCrossChain() + + staticNetworkView.bind(viewModel: origin) + selectableNetworkView?.bind(viewModel: destination) } setupLocalization() } private func setupOnChain() { - destinationNetworkView?.removeFromSuperview() - destinationNetworkView = nil + selectableNetworkView?.removeFromSuperview() + selectableNetworkView = nil toLabel?.removeFromSuperview() toLabel = nil } private func setupCrossChain() { - guard destinationNetworkView == nil else { + guard selectableNetworkView == nil else { return } @@ -102,9 +109,9 @@ final class TransferNetworkContainerView: UIView { addSubview(label) toLabel = label - let destNetworkView = AssetListChainControlView() - addSubview(destNetworkView) - destinationNetworkView = destNetworkView + let networkView = AssetListChainControlView() + addSubview(networkView) + selectableNetworkView = networkView } override var intrinsicContentSize: CGSize { @@ -114,9 +121,20 @@ final class TransferNetworkContainerView: UIView { override func layoutSubviews() { super.layoutSubviews() + let originView: UIView? + let destinationView: UIView? + + if case .selectableOrigin = viewModel?.mode, let selectableNetworkView = selectableNetworkView { + originView = selectableNetworkView + destinationView = staticNetworkView + } else { + originView = staticNetworkView + destinationView = selectableNetworkView + } + let tokenLabelSize = tokenLabel.intrinsicContentSize let fromLabelSize = fromLabel.intrinsicContentSize - let originViewSize = originNetworkView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + let originViewSize = originView?.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) ?? .zero let totalOneLineWidth = tokenLabelSize.width + horizontalSpacing + fromLabelSize.width + horizontalSpacing + originViewSize.width @@ -133,7 +151,7 @@ final class TransferNetworkContainerView: UIView { height: fromLabelSize.height ) - originNetworkView.frame = CGRect( + originView?.frame = CGRect( x: fromLabel.frame.maxX + horizontalSpacing, y: tokenLabel.frame.midY - originViewSize.height / 2.0, width: originViewSize.width, @@ -149,7 +167,7 @@ final class TransferNetworkContainerView: UIView { height: fromLabelSize.height ) - originNetworkView.frame = CGRect( + originView?.frame = CGRect( x: fromLabel.frame.maxX + horizontalSpacing, y: fromLabel.frame.midY - originViewSize.height / 2.0, width: originViewSize.width, @@ -160,9 +178,9 @@ final class TransferNetworkContainerView: UIView { max(fromLabelSize.height, originViewSize.height) } - if let destinationNetworkView = destinationNetworkView, let toLabel = toLabel { + if let destinationView = destinationView, let toLabel = toLabel { let toLabelSize = toLabel.intrinsicContentSize - let destViewSize = destinationNetworkView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + let destViewSize = destinationView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) toLabel.frame = CGRect( x: bounds.minX, @@ -171,7 +189,7 @@ final class TransferNetworkContainerView: UIView { height: toLabelSize.height ) - destinationNetworkView.frame = CGRect( + destinationView.frame = CGRect( x: toLabel.frame.maxX + horizontalSpacing, y: toLabel.frame.midY - originViewSize.height / 2.0, width: destViewSize.width, diff --git a/novawallet/Modules/Transfer/View/TransferNetworkContainerViewModel.swift b/novawallet/Modules/Transfer/View/TransferNetworkContainerViewModel.swift index f8d244a613..f85d629552 100644 --- a/novawallet/Modules/Transfer/View/TransferNetworkContainerViewModel.swift +++ b/novawallet/Modules/Transfer/View/TransferNetworkContainerViewModel.swift @@ -1,7 +1,21 @@ import Foundation struct TransferNetworkContainerViewModel { + enum Mode { + case onchain(NetworkViewModel) + case selectableOrigin(NetworkViewModel, NetworkViewModel) + case selectableDestination(NetworkViewModel, NetworkViewModel) + } + let assetSymbol: String - let originNetwork: NetworkViewModel - let destNetwork: NetworkViewModel? + let mode: Mode + + var isCrosschain: Bool { + switch mode { + case .onchain: + return false + case .selectableDestination, .selectableOrigin: + return true + } + } } diff --git a/novawallet/Modules/Transfer/View/Web3NameReceipientView.swift b/novawallet/Modules/Transfer/View/Web3NameReceipientView.swift index 22bdcadb4b..d130030fac 100644 --- a/novawallet/Modules/Transfer/View/Web3NameReceipientView.swift +++ b/novawallet/Modules/Transfer/View/Web3NameReceipientView.swift @@ -71,7 +71,7 @@ extension Web3NameReceipientView { accountSelected.isHidden = false accountSelected.detailsView.detailsLabel.text = value accountSelected.detailsView.imageView.image = R.image.iconAlgoItem() - accountSelected.imageView.image = R.image.iconInfoFilled()?.tinted(with: R.color.colorIconSecondary()!) + accountSelected.imageView.image = R.image.iconInfoFilled() } else { accountSelected.detailsView.detailsLabel.text = "" accountSelected.detailsView.imageView.image = nil diff --git a/novawallet/Modules/Vote/Crowdloan/CustomCrowdloan/Acala/ContributionSetup/AcalaContributionSetupViewLayout.swift b/novawallet/Modules/Vote/Crowdloan/CustomCrowdloan/Acala/ContributionSetup/AcalaContributionSetupViewLayout.swift index 22db2d2f0d..0478b05160 100644 --- a/novawallet/Modules/Vote/Crowdloan/CustomCrowdloan/Acala/ContributionSetup/AcalaContributionSetupViewLayout.swift +++ b/novawallet/Modules/Vote/Crowdloan/CustomCrowdloan/Acala/ContributionSetup/AcalaContributionSetupViewLayout.swift @@ -38,7 +38,7 @@ final class AcalaContributionSetupViewLayout: CrowdloanContributionSetupViewLayo } let iconView = UIImageView() - let iconimage = R.image.iconInfoFilled()!.withRenderingMode(.alwaysTemplate) + let iconimage = R.image.iconInfoFilled() iconView.image = iconimage iconView.tintColor = R.color.colorButtonTextAccent()! iconView.contentMode = .scaleAspectFit diff --git a/novawallet/Modules/Vote/Governance/CommonVotes/VotesContentView.swift b/novawallet/Modules/Vote/Governance/CommonVotes/VotesContentView.swift index 24c4b10ac4..949e94868a 100644 --- a/novawallet/Modules/Vote/Governance/CommonVotes/VotesContentView.swift +++ b/novawallet/Modules/Vote/Governance/CommonVotes/VotesContentView.swift @@ -75,7 +75,7 @@ final class VotesContentView: GenericTitleValueView>? + + func spentAmount() -> BigUInt? { + guard !amountSpendDetailsList.isEmpty else { + return nil + } + + return amountSpendDetailsList.reduce(BigUInt(0)) { $0 + $1.amount } + } + + var beneficiary: MultiAddress? { + amountSpendDetailsList.first?.beneficiary + } } diff --git a/novawallet/Modules/Vote/Governance/Operation/Action/GovernanceActionOperationFactory.swift b/novawallet/Modules/Vote/Governance/Operation/Action/GovernanceActionOperationFactory.swift index e056593f16..880e30c3c2 100644 --- a/novawallet/Modules/Vote/Governance/Operation/Action/GovernanceActionOperationFactory.swift +++ b/novawallet/Modules/Vote/Governance/Operation/Action/GovernanceActionOperationFactory.swift @@ -100,7 +100,7 @@ class GovernanceActionOperationFactory { codingFactoryOperation: BaseOperation, connection: JSONRPCEngine, requestFactory: StorageRequestFactoryProtocol - ) -> CompoundOperationWrapper { + ) -> CompoundOperationWrapper<[ReferendumActionLocal.AmountSpendDetails]> { let operationManager = OperationManager(operationQueue: operationQueue) let fetchService = OperationCombiningService( operationManager: operationManager @@ -110,59 +110,24 @@ class GovernanceActionOperationFactory { } let codingFactory = try codingFactoryOperation.extractNoCancellableResultData() - let context = codingFactory.createRuntimeJsonContext() - - let codingPath = CallCodingPath(moduleName: call.moduleName, callName: call.callName) - - if codingPath == Treasury.spendCallPath { - let spendCall = try call.args.map(to: Treasury.SpendCall.self, with: context.toRawContext()) - - let details = ReferendumActionLocal.AmountSpendDetails( - amount: spendCall.amount, - beneficiary: spendCall.beneficiary - ) - - return [CompoundOperationWrapper.createWithResult(details)] - } - - if codingPath == Treasury.approveProposalCallPath { - let approveCall = try call.args.map(to: Treasury.ApproveProposal.self, with: context.toRawContext()) - - let keyClosure: () throws -> [StringScaleMapper] = { - [StringScaleMapper(value: approveCall.proposalId)] - } - - let wrapper: CompoundOperationWrapper<[StorageResponse]> = requestFactory.queryItems( - engine: connection, - keyParams: keyClosure, - factory: { codingFactory }, - storagePath: Treasury.proposalsStoragePath - ) - - let mapOperation = ClosureOperation { - let responses = try wrapper.targetOperation.extractNoCancellableResultData() - guard let proposal = responses.first?.value else { - return nil - } - - return ReferendumActionLocal.AmountSpendDetails( - amount: proposal.value, - beneficiary: .accoundId(proposal.beneficiary) - ) - } - - mapOperation.addDependency(wrapper.targetOperation) - - return [CompoundOperationWrapper(targetOperation: mapOperation, dependencies: wrapper.allOperations)] - } + let context = GovSpentAmount.Context( + codingFactory: codingFactory, + connection: connection, + requestFactory: requestFactory + ) - return [CompoundOperationWrapper.createWithResult(nil)] + return try GovSpentAmount.Extractor.defaultExtractor.createExtractionWrappers( + from: call, + context: context + ) ?? [] } let fetchOperation = fetchService.longrunOperation() - let mapOperation = ClosureOperation { - try fetchOperation.extractNoCancellableResultData().first ?? nil + let mapOperation = ClosureOperation<[ReferendumActionLocal.AmountSpendDetails]> { + let details = try fetchOperation.extractNoCancellableResultData() + + return details.compactMap { $0 } } mapOperation.addDependency(fetchOperation) @@ -199,9 +164,9 @@ extension GovernanceActionOperationFactory: ReferendumActionOperationFactoryProt let mapOperation = ClosureOperation { let call = try callFetchWrapper.targetOperation.extractNoCancellableResultData() - let amountDetails = try amountDetailsWrapper.targetOperation.extractNoCancellableResultData() + let amountDetailsList = try amountDetailsWrapper.targetOperation.extractNoCancellableResultData() - return ReferendumActionLocal(amountSpendDetails: amountDetails, call: call) + return ReferendumActionLocal(amountSpendDetailsList: amountDetailsList, call: call) } mapOperation.addDependency(callFetchWrapper.targetOperation) diff --git a/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovSpentAmountBatchHandler.swift b/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovSpentAmountBatchHandler.swift new file mode 100644 index 0000000000..06e1a621f8 --- /dev/null +++ b/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovSpentAmountBatchHandler.swift @@ -0,0 +1,50 @@ +import Foundation +import SubstrateSdk +import RobinHood + +extension GovSpentAmount { + final class BatchHandler { + private func handleInternal( + call: RuntimeCall, + handlers: [GovSpentAmountHandling], + context: GovSpentAmount.Context + ) throws -> [CompoundOperationWrapper]? { + for handler in handlers { + if + let wrappers = try handler.handle( + call: call, + internalHandlers: handlers, + context: context + ) { + return wrappers + } + } + + return nil + } + } +} + +extension GovSpentAmount.BatchHandler: GovSpentAmountHandling { + func handle( + call: RuntimeCall, + internalHandlers: [GovSpentAmountHandling], + context: GovSpentAmount.Context + ) throws -> [CompoundOperationWrapper]? { + let path = CallCodingPath(moduleName: call.moduleName, callName: call.callName) + + guard UtilityPallet.isBatch(path: path) else { + return nil + } + + let runtimeContext = context.codingFactory.createRuntimeJsonContext() + + let calls = try call.args.map(to: UtilityPallet.Call.self, with: runtimeContext.toRawContext()).calls + + let wrappers = try calls.flatMap { call in + try handleInternal(call: call, handlers: internalHandlers, context: context) ?? [] + } + + return wrappers + } +} diff --git a/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovSpentAmountExtractor.swift b/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovSpentAmountExtractor.swift new file mode 100644 index 0000000000..aaebfa5feb --- /dev/null +++ b/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovSpentAmountExtractor.swift @@ -0,0 +1,56 @@ +import Foundation +import SubstrateSdk +import RobinHood + +protocol GovSpentAmountHandling { + func handle( + call: RuntimeCall, + internalHandlers: [GovSpentAmountHandling], + context: GovSpentAmount.Context + ) throws -> [CompoundOperationWrapper]? +} + +enum GovSpentAmount { + struct Context { + let codingFactory: RuntimeCoderFactoryProtocol + let connection: JSONRPCEngine + let requestFactory: StorageRequestFactoryProtocol + } + + final class Extractor { + let handlers: [GovSpentAmountHandling] + + init(handlers: [GovSpentAmountHandling]) { + self.handlers = handlers + } + + func createExtractionWrappers( + from call: RuntimeCall, + context: GovSpentAmount.Context + ) throws -> [CompoundOperationWrapper]? { + for handler in handlers { + if let wrappers = try handler.handle( + call: call, + internalHandlers: handlers, + context: context + ) { + return wrappers + } + } + + return nil + } + } +} + +extension GovSpentAmount.Extractor { + static var defaultExtractor: GovSpentAmount.Extractor { + .init( + handlers: [ + GovSpentAmount.BatchHandler(), + GovSpentAmount.TreasurySpentHandler(), + GovSpentAmount.TreasuryApproveHandler() + ] + ) + } +} diff --git a/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovTreasuryApproveHandler.swift b/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovTreasuryApproveHandler.swift new file mode 100644 index 0000000000..f77a0ea458 --- /dev/null +++ b/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovTreasuryApproveHandler.swift @@ -0,0 +1,61 @@ +import Foundation +import SubstrateSdk +import RobinHood + +extension GovSpentAmount { + final class TreasuryApproveHandler {} +} + +extension GovSpentAmount.TreasuryApproveHandler: GovSpentAmountHandling { + func handle( + call: RuntimeCall, + internalHandlers _: [GovSpentAmountHandling], + context: GovSpentAmount.Context + ) throws -> [CompoundOperationWrapper]? { + let path = CallCodingPath(moduleName: call.moduleName, callName: call.callName) + + guard path == Treasury.approveProposalCallPath else { + return nil + } + + let runtimeContext = context.codingFactory.createRuntimeJsonContext() + let approveCall = try call.args.map( + to: Treasury.ApproveProposal.self, + with: runtimeContext.toRawContext() + ) + + let keyClosure: () throws -> [StringScaleMapper] = { + [StringScaleMapper(value: approveCall.proposalId)] + } + + let wrapper: CompoundOperationWrapper<[StorageResponse]> = context.requestFactory.queryItems( + engine: context.connection, + keyParams: keyClosure, + factory: { context.codingFactory }, + storagePath: Treasury.proposalsStoragePath + ) + + let mapOperation = ClosureOperation { + let responses = try wrapper.targetOperation.extractNoCancellableResultData() + guard let proposal = responses.first?.value else { + return nil + } + + let details = ReferendumActionLocal.AmountSpendDetails( + amount: proposal.value, + beneficiary: .accoundId(proposal.beneficiary) + ) + + return details + } + + mapOperation.addDependency(wrapper.targetOperation) + + let resultWrapper = CompoundOperationWrapper( + targetOperation: mapOperation, + dependencies: wrapper.allOperations + ) + + return [resultWrapper] + } +} diff --git a/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovTreasurySpentAmountHandler.swift b/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovTreasurySpentAmountHandler.swift new file mode 100644 index 0000000000..a4bd77f3eb --- /dev/null +++ b/novawallet/Modules/Vote/Governance/Operation/Action/Parser/GovTreasurySpentAmountHandler.swift @@ -0,0 +1,35 @@ +import Foundation +import SubstrateSdk +import RobinHood + +extension GovSpentAmount { + final class TreasurySpentHandler {} +} + +extension GovSpentAmount.TreasurySpentHandler: GovSpentAmountHandling { + func handle( + call: RuntimeCall, + internalHandlers _: [GovSpentAmountHandling], + context: GovSpentAmount.Context + ) throws -> [CompoundOperationWrapper]? { + let path = CallCodingPath(moduleName: call.moduleName, callName: call.callName) + + guard path == Treasury.spendCallPath else { + return nil + } + + let operation = ClosureOperation { + let runtimeContext = context.codingFactory.createRuntimeJsonContext() + let spentCall = try call.args.map(to: Treasury.SpendCall.self, with: runtimeContext.toRawContext()) + + return ReferendumActionLocal.AmountSpendDetails( + amount: spentCall.amount, + beneficiary: spentCall.beneficiary + ) + } + + let wrapper = CompoundOperationWrapper(targetOperation: operation) + + return [wrapper] + } +} diff --git a/novawallet/Modules/Vote/Governance/ReferendumDetails/ReferendumDetailsPresenter.swift b/novawallet/Modules/Vote/Governance/ReferendumDetails/ReferendumDetailsPresenter.swift index 5628aba7e6..d69f0266c8 100644 --- a/novawallet/Modules/Vote/Governance/ReferendumDetails/ReferendumDetailsPresenter.swift +++ b/novawallet/Modules/Vote/Governance/ReferendumDetails/ReferendumDetailsPresenter.swift @@ -136,7 +136,7 @@ final class ReferendumDetailsPresenter { private func provideRequestedAmount() { guard - let requestedAmount = actionDetails?.amountSpendDetails?.amount, + let requestedAmount = actionDetails?.spentAmount(), let precision = chain.utilityAssetDisplayInfo()?.assetPrecision, let decimalAmount = Decimal.fromSubstrateAmount(requestedAmount, precision: precision) else { view?.didReceive(requestedAmount: nil) @@ -355,7 +355,7 @@ final class ReferendumDetailsPresenter { accountIds.insert(proposer) } - if let beneficiary = actionDetails?.amountSpendDetails?.beneficiary.accountId { + if let beneficiary = actionDetails?.beneficiary?.accountId { accountIds.insert(beneficiary) } diff --git a/novawallet/Modules/Vote/Governance/ReferendumDetails/View/ReferendumDetailsTitleView.swift b/novawallet/Modules/Vote/Governance/ReferendumDetails/View/ReferendumDetailsTitleView.swift index f8c82b3ecd..3b4d891867 100644 --- a/novawallet/Modules/Vote/Governance/ReferendumDetails/View/ReferendumDetailsTitleView.swift +++ b/novawallet/Modules/Vote/Governance/ReferendumDetails/View/ReferendumDetailsTitleView.swift @@ -21,7 +21,7 @@ final class ReferendumDetailsTitleView: UIView { view.rowContentView.iconWidth = 16.0 view.rowContentView.spacing = 6 view.contentInsets = UIEdgeInsets(top: 9, left: 0, bottom: 9, right: 0) - view.rowContentView.imageView.image = R.image.iconInfoFilled()?.tinted(with: R.color.colorIconSecondary()!) + view.rowContentView.imageView.image = R.image.iconInfoFilled() let addressView = view.rowContentView.detailsView addressView.spacing = 7 diff --git a/novawallet/Modules/Vote/Governance/ReferendumDetails/View/ReferendumVotingStatusDetailsView.swift b/novawallet/Modules/Vote/Governance/ReferendumDetails/View/ReferendumVotingStatusDetailsView.swift index ccfb6dd9a8..dfbddeacbd 100644 --- a/novawallet/Modules/Vote/Governance/ReferendumDetails/View/ReferendumVotingStatusDetailsView.swift +++ b/novawallet/Modules/Vote/Governance/ReferendumDetails/View/ReferendumVotingStatusDetailsView.swift @@ -7,14 +7,14 @@ final class ReferendumVotingStatusDetailsView: RoundedView { let ayeVotesView: VoteRowView = .create { $0.apply(style: .init( color: R.color.colorIconPositive()!, - accessoryImage: (R.image.iconInfoFilled()?.tinted(with: R.color.colorIconSecondary()!))! + accessoryImage: R.image.iconInfoFilled()! )) } let nayVotesView: VoteRowView = .create { $0.apply(style: .init( color: R.color.colorIconNegative()!, - accessoryImage: (R.image.iconInfoFilled()?.tinted(with: R.color.colorIconSecondary()!))! + accessoryImage: R.image.iconInfoFilled()! )) } diff --git a/novawallet/Modules/Vote/Governance/ReferendumFullDetails/ReferendumFullDetailsPresenter.swift b/novawallet/Modules/Vote/Governance/ReferendumFullDetails/ReferendumFullDetailsPresenter.swift index 882cd2c139..44aeb1578a 100644 --- a/novawallet/Modules/Vote/Governance/ReferendumFullDetails/ReferendumFullDetailsPresenter.swift +++ b/novawallet/Modules/Vote/Governance/ReferendumFullDetails/ReferendumFullDetailsPresenter.swift @@ -88,10 +88,10 @@ final class ReferendumFullDetailsPresenter { private func provideBeneficiaryViewModel() { guard let beneficiary = getAccountViewModel( - actionDetails.amountSpendDetails?.beneficiary.accountId + actionDetails.beneficiary?.accountId ), let amount = getBalanceViewModel( - actionDetails.amountSpendDetails?.amount, + actionDetails.spentAmount(), locale: selectedLocale ) else { view?.didReceive(beneficiary: nil) @@ -172,7 +172,7 @@ extension ReferendumFullDetailsPresenter: ReferendumFullDetailsPresenterProtocol func presentBeneficiary() { guard - let address = try? actionDetails.amountSpendDetails?.beneficiary.accountId?.toAddress( + let address = try? actionDetails.beneficiary?.accountId?.toAddress( using: chain.chainFormat ) else { return diff --git a/novawallet/en.lproj/InfoPlist.strings b/novawallet/en.lproj/InfoPlist.strings index 9c21500255..99fa3b86ad 100644 --- a/novawallet/en.lproj/InfoPlist.strings +++ b/novawallet/en.lproj/InfoPlist.strings @@ -2,4 +2,4 @@ "NSFaceIDUsageDescription" = "Face ID is used to authorize in Nova Wallet"; "NSPhotoLibraryUsageDescription" = "Photo library access is used to allow selection of QR code with addresses, Polkadot Vault transactions, or connect via WalletConnect"; "NSPhotoLibraryAddUsageDescription" = "Save transfer request as a qr code"; -"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with Ledger Nano X devices"; +"NSBluetoothAlwaysUsageDescription" = "Bluetooth is used to communicate with Ledger Nano X devices"; \ No newline at end of file diff --git a/novawallet/en.lproj/Localizable.strings b/novawallet/en.lproj/Localizable.strings index ea8d826ba8..8fa251540d 100644 --- a/novawallet/en.lproj/Localizable.strings +++ b/novawallet/en.lproj/Localizable.strings @@ -534,6 +534,7 @@ "common.unknown" = "Unknown"; "staking.month.period.title" = "Monthly"; "staking.year.period.title" = "Yearly"; +"common.positive.amount" = "Amount must be positive"; "qr.scan.error.no.info" = "QR can't be decoded"; "qr.scan.upload.gallery" = "Upload from gallery"; "staking.unstake.crossed.min.message" = "When unstaking partially, you should leave at least %@ in stake. Do you want to perform full unstake by unstaking remaining %@ as well?"; @@ -951,7 +952,6 @@ "with.yield.boost" = "with Yield Boost"; "without.yield.boost" = "without Yield Boost"; "common.no.changes" = "No changes"; -"common.not.enough.fee.message_v3.8.0" = "You don’t have enough balance to pay the network fee of %@.\nAvailable balance to pay fee after operation: %@"; "yield.boost.not.enough.execution.fee.message" = "You don’t have enough balance to pay the network fee of %@ and the yield boost execution fee of %@.\nAvailable balance to pay the fee: %@"; "yield.boost.not.enough.execution.fee.title" = "Not enough tokens to pay first execution fee"; "yield.boost.not.enough.threshold.message" = "You don’t have enough balance to pay the network fee of %@ and not drop below the threshold %@.\nAvailable balance to pay the fee: %@"; @@ -1378,3 +1378,54 @@ "common.alert.external.link.disclaimer.message" = "To continue the purchase you will be redirected from Nova Wallet app to %@"; "polkadot.staking.promotion.title" = "Boost your DOT 🚀"; "polkadot.staking.promotion.message" = "Received your DOT back from crowdloans? Start staking your DOT today to get the maximum possible rewards!"; +"swaps.violating.consumers.message" = "You should keep at least %@ after paying %@ network fee as you are holding non sufficient tokens"; +"common.receive.not.sufficient.native.asset.error" = "You must keep at least %@ to receive %@ token"; +"swaps.rate.description" = "Exchange rate between two different cryptocurrencies. It represents how much of one cryptocurrency you can get in exchange for a certain amount of another cryptocurrency."; +"swaps.setup.price.difference.description" = "Price difference refers to the difference in price between two different assets. When making a swap in crypto, the price difference is usually the difference between the price of the asset you are swapping for and the price of the asset you are swapping with."; +"swaps.setup.slippage.description" = "Swap slippage is a common occurrence in decentralized trading where the final price of a swap transaction might slightly differ from the expected price, due to changing market conditions."; +"swaps.network.fee.description" = "A network fees charged by the blockchain to process and validate any transactions. May vary depending on network conditions or transaction speed."; +"swaps.setup.asset.action.select.pay" = "Select a token to pay"; +"swaps.not.enough.liquidity" = "Not enough liquidity"; +"swaps.not.enough.tokens" = "Not enough tokens to swap"; +"common.receive.at.least.ed.error" = "You can’t receive less than %@"; +"swaps.error.rate.was.updated.title" = "Swap rate was updated"; +"swaps.setup.error.not.enough.liquidity.title" = "Pool doesn’t have enough liquidity to swap"; +"common.dust.remains.title" = "Too small amount remains on your balance"; +"swaps.dust.remains.fee.native.asset.message" = "You should leave at least %1$@ on your balance. Do you want to perform full swap by adding remaining %2$@ as well?"; +"swaps.dust.remains.fee.pay.asset.message" = "You should keep at least %1$@ after paying %2$@ network fee and converting %3$@ to %4$@ to meet %5$@ minimum balance.\n\nDo you want to fully swap by adding remaining %6$@ as well?"; +"swaps.setup.error.insufficient.balance.fee.native.message" = "You can swap up to %1$@ since you need to pay %2$@ for network fee."; +"swaps.setup.error.insufficient.balance.fee.swap.message" = "You can swap up to %1$@ since you need to pay %2$@ for network fee and also convert %3$@ to %4$@ to meet %5$@ minimum balance."; +"common.swap.max" = "Swap max"; +"swaps.setup.deposit.by.cross.chain.transfer.title" = "Cross-chain transfer"; +"swaps.setup.deposit.title" = "Get %@ using"; +"swaps.setup.deposit.by.cross.chain.transfer.subtitle" = "Transfer %@ from another network"; +"swaps.setup.deposit.by.receive.subtitle" = "Receive %@ with QR or your address"; +"swaps.setup.deposit.by.buy.subtitle" = "Instantly buy %@ with a credit card"; +"swaps.setup.deposit.button.title" = "Get %@"; +"swaps.setup.network.fee.token.title" = "Token for paying network fee"; +"swaps.setup.network.fee.token.hint" = "Network fee is added on top of entered amount"; +"swaps.pay.asset.fee.ed.message" = "To pay network fee with %@, Nova will automatically swap %@ for %@ to maintain your account\'s minimum %@ balance."; +"swaps.setup.slippage.error.amount.bounds" = "Enter a value between %@ and %@"; +"swaps.setup.slippage.warning.low.amount" = "Transaction might be reverted because of low slippage tolerance."; +"swaps.setup.slippage.warning.high.amount" = "Transaction might be frontrun because of high slippage."; +"swaps.setup.slippage" = "Slippage"; +"swaps.setup.settings.title" = "Swap settings"; +"swaps.pay.token.selection.title" = "Token to pay"; +"swaps.receive.token.selection.title" = "Token to receive"; +"swaps.setup.asset.select.subtitle" = "Select a token"; +"swaps.setup.asset.pay.title" = "Pay"; +"swaps.setup.asset.receive.title" = "Receive"; +"swaps.setup.details.rate" = "Rate"; +"swaps.setup.price.difference" = "Price difference"; +"swaps.setup.asset.max" = "Max:"; +"swaps.setup.asset.select.pay.title" = "You pay"; +"swaps.setup.asset.select.receive.title" = "You receive"; +"swaps.setup.details.title" = "Swap details"; +"swaps.setup.asset.action.select.receive" = "Select a token to receive"; +"common.swap.action" = "Swap"; +"swaps.setup.asset.action.enter.amount" = "Enter amount"; +"common.swap.title" = "Swap"; +"common.action.repeat.operation" = "Repeat the operation"; +"swaps.error.rate.was.updated.message" = "Old rate: %@.\nNew rate: %@"; +"settings.wiki" = "Wiki"; +"common.not.enough.fee.message_v3.8.0" = "You don’t have enough balance to pay the network fee of %@.\nAvailable balance to pay fee after operation: %@"; \ No newline at end of file diff --git a/novawallet/ru.lproj/InfoPlist.strings b/novawallet/ru.lproj/InfoPlist.strings index 3a4b45f510..56ce3b8975 100644 --- a/novawallet/ru.lproj/InfoPlist.strings +++ b/novawallet/ru.lproj/InfoPlist.strings @@ -2,4 +2,4 @@ "NSFaceIDUsageDescription" = "Face ID используется для авторизации в Nova Wallet"; "NSPhotoLibraryUsageDescription" = "Доступ к фотогалерее используется для выбора QR-кода с адресами, транзакциями Polkadot Vault или подключения через WalletConnect"; "NSPhotoLibraryAddUsageDescription" = "Вы можете сохранить запрос на перевод в виде QR кода"; -"NSBluetoothAlwaysUsageDescription" = "Bluetooth используется для взаимодействия с устройствами Ledger Nano X"; +"NSBluetoothAlwaysUsageDescription" = "Bluetooth используется для взаимодействия с устройствами Ledger Nano X"; \ No newline at end of file diff --git a/novawallet/ru.lproj/Localizable.strings b/novawallet/ru.lproj/Localizable.strings index ee84d1cd86..71e0a226f9 100644 --- a/novawallet/ru.lproj/Localizable.strings +++ b/novawallet/ru.lproj/Localizable.strings @@ -534,6 +534,7 @@ "common.unknown" = "Неизвестно"; "staking.month.period.title" = "Eжемесячно"; "staking.year.period.title" = "Eжегодно"; +"common.positive.amount" = "Сумма должна быть больше нуля"; "qr.scan.error.no.info" = "Не удается декодировать QR"; "qr.scan.upload.gallery" = "Из галереи"; "staking.unstake.crossed.min.message" = "При частичном выводе средств вы должны оставить в стейке не менее %@. Хотите ли вы полностью вывести средства, также разблокировав оставшиеся %@?"; @@ -951,7 +952,6 @@ "with.yield.boost" = "с Yield Boost"; "without.yield.boost" = "без Yield Boost"; "common.no.changes" = "Нет изменений"; -"common.not.enough.fee.message_v3.8.0" = "У вас недостаточно средств для оплаты комиссии сети в размере %@.\nДоступный баланс для оплаты комиссии после операции: %@"; "yield.boost.not.enough.execution.fee.message" = "У вас недостаточно средств для оплаты сетевой комиссии в размере %@ и комиссии за выполнение первой Yield Boost операции в размере %@. \nДоступный баланс для оплаты комиссии: %@"; "yield.boost.not.enough.execution.fee.title" = "Недостаточно токенов для оплаты комиссии за первое исполнение"; "yield.boost.not.enough.threshold.message" = "У вас недостаточно средств, чтобы оплатить комиссию сети в размере %@ и не опуститься ниже порога %@.\nДоступный баланс для оплаты комиссии: %@"; @@ -1376,5 +1376,56 @@ "governance.referendums.status.deciding" = "Решение"; "common.alert.external.link.disclaimer.title" = "Продолжить в браузере?"; "common.alert.external.link.disclaimer.message" = "Для продолжения покупки вы будете перенаправлены из приложения Nova Wallet на сайт %@"; -"polkadot.staking.promotion.title" = "Максимизируйте\nнаграды от DOT 🚀"; +"polkadot.staking.promotion.title" = "Максимизируйте награды от DOT 🚀"; "polkadot.staking.promotion.message" = "Получили свои DOT из краудлоунов? Начните стейкать DOT уже сегодня, чтобы получить максимальные вознаграждения!"; +"swaps.violating.consumers.message" = "Вы должны сохранить как минимум %@ после оплаты сетевой комиссии %@, поскольку вы храните не самодостаточные токены"; +"common.receive.not.sufficient.native.asset.error" = "Вы должны сохранить как минимум %@, чтобы получить %@ токены"; +"swaps.rate.description" = "Курс обмена двух разных криптовалют. Он показывает, сколько криптовалюты вы можете получить в обмен на определенное количество другой криптовалюты."; +"swaps.setup.price.difference.description" = "Разница в цене представляет собой разницу между двумя различными активами. При обмене криптовалюты под разницей в цене обычно имеется ввиду разница между ценой актива, которую вы получаете и ценой актива, которую вы платите."; +"swaps.setup.slippage.description" = "Проскальзывание - распространенное явление в децентрализованной торговле, когда конечная цена сделки по обмену может незначительно отличаться от ожидаемой в связи с изменением рыночных условий."; +"swaps.network.fee.description" = "Комиссия сети, взимается блокчейном за обработку и проверку транзакций. Может варьироваться в зависимости от условий сети или скорости транзакции."; +"swaps.setup.asset.action.select.pay" = "Выберите токен для оплаты"; +"swaps.not.enough.liquidity" = "Недостаточно ликвидности"; +"swaps.not.enough.tokens" = "Недостаточно токенов для обмена"; +"common.receive.at.least.ed.error" = "Вы не можете получить меньше %@"; +"swaps.error.rate.was.updated.title" = "Обменный курс изменился"; +"swaps.setup.error.not.enough.liquidity.title" = "В пуле недостаточно ликвидности для обмена"; +"common.dust.remains.title" = "На вашем балансе остается слишком маленькая сумма"; +"swaps.dust.remains.fee.native.asset.message" = "На вашем балансе должно оставаться не менее %1$@. Хотите обменять максимум, добавив также оставшиеся %2$@?"; +"swaps.dust.remains.fee.pay.asset.message" = "На вашем балансе должно оставаться как минимум %1$@ после оплаты сетевой комиссии %2$@ и конвертации %3$@ в %4$@, для достижения минимального баланса %5$@.\n\nХотите обменять максимум, добавив еще %6$@?"; +"swaps.setup.error.insufficient.balance.fee.native.message" = "Вы можете обменять до %1$@ так как вам нужно заплатить комиссию сети в размере %2$@."; +"swaps.setup.error.insufficient.balance.fee.swap.message" = "Вы можете обменять до %1$@ так как вам нужно заплатить комиссию сети в размере %2$@, а также конвертировать %3$@ в %4$@, чтобы сохранить минимальный баланс %5$@."; +"common.swap.max" = "Использовать максимум"; +"swaps.setup.deposit.by.cross.chain.transfer.title" = "Межсетевой перевод"; +"swaps.setup.deposit.title" = "Получить %@ с помощью"; +"swaps.setup.deposit.by.cross.chain.transfer.subtitle" = "Перевести %@ из другой сети"; +"swaps.setup.deposit.by.receive.subtitle" = "Получить %@ по QR или вашему адресу"; +"swaps.setup.deposit.by.buy.subtitle" = "Мгновенная покупка %@ с помощью кредитной карты"; +"swaps.setup.deposit.button.title" = "Получить %@"; +"swaps.setup.network.fee.token.title" = "Токен для оплаты комиссии сети"; +"swaps.setup.network.fee.token.hint" = "Комиссия сети добавится к введенной сумме"; +"swaps.pay.asset.fee.ed.message" = "Чтобы оплатить комиссию сети с помощью %@, Nova автоматически обменяет %@ на %@, чтобы поддерживать минимальный %@ баланс вашей учетной записи."; +"swaps.setup.slippage.error.amount.bounds" = "Введите значение между %@ и %@"; +"swaps.setup.slippage.warning.low.amount" = "Транзакция может быть отменена из-за низкой устойчивости к проскальзыванию."; +"swaps.setup.slippage.warning.high.amount" = "Транзакция может подвергнуться фронтрану из-за высокого проскальзывания"; +"swaps.setup.slippage" = "Проскальзывание"; +"swaps.setup.settings.title" = "Настройки обмена"; +"swaps.pay.token.selection.title" = "Токен для оплаты"; +"swaps.receive.token.selection.title" = "Токен для получения"; +"swaps.setup.asset.select.subtitle" = "Выберите токен"; +"swaps.setup.asset.pay.title" = "Заплатить"; +"swaps.setup.asset.receive.title" = "Получить"; +"swaps.setup.details.rate" = "Курс"; +"swaps.setup.price.difference" = "Разница в цене"; +"swaps.setup.asset.max" = "Макс:"; +"swaps.setup.asset.select.pay.title" = "Вы платите"; +"swaps.setup.asset.select.receive.title" = "Вы получаете"; +"swaps.setup.details.title" = "Детали обмена"; +"swaps.setup.asset.action.select.receive" = "Выберите токен для получения"; +"common.swap.action" = "Обменять"; +"swaps.setup.asset.action.enter.amount" = "Введите сумму"; +"common.swap.title" = "Обмен"; +"common.action.repeat.operation" = "Повторить операцию"; +"swaps.error.rate.was.updated.message" = "Было: %@.\nСтало: %@"; +"settings.wiki" = "Руководство пользователя"; +"common.not.enough.fee.message_v3.8.0" = "У вас недостаточно средств для оплаты комиссии сети в размере %@.\nДоступный баланс для оплаты комиссии после операции: %@"; diff --git a/novawalletIntegrationTests/AssetHubSwapTests.swift b/novawalletIntegrationTests/AssetHubSwapTests.swift new file mode 100644 index 0000000000..5782e8d51d --- /dev/null +++ b/novawalletIntegrationTests/AssetHubSwapTests.swift @@ -0,0 +1,258 @@ +import XCTest +@testable import novawallet +import BigInt +import RobinHood + +final class AssetHubSwapTests: XCTestCase { + func testWestmintAllDirections() throws { + let directions = try performAvailableDirectionsFetch( + for: KnowChainId.westmint, + assetId: nil + ) + + Logger.shared.info("Directions: \(directions)") + } + + func testWestmintNativeDirections() throws { + let directions = try performAvailableDirectionsFetch( + for: KnowChainId.westmint, + assetId: 0 + ) + + Logger.shared.info("Directions: \(directions)") + } + + func testWestmintSiriDirections() throws { + let directions = try performAvailableDirectionsFetch( + for: KnowChainId.westmint, + assetId: 1 + ) + + Logger.shared.info("Directions: \(directions)") + } + + func testQuoteForWestmintSiriSell() throws { + let quote = try fetchQuote( + for: KnowChainId.westmint, + assetIn: 0, + assetOut: 1, + direction: .sell + ) + + Logger.shared.info("Quote: \(quote)") + } + + func testQuoteForWestmintSiriBuy() throws { + let quote = try fetchQuote( + for: KnowChainId.westmint, + assetIn: 0, + assetOut: 1, + direction: .buy, + amount: 1_000_000 + ) + + Logger.shared.info("Quote: \(quote)") + } + + func testQuoteForSiriWestmintSell() throws { + let quote = try fetchQuote( + for: KnowChainId.westmint, + assetIn: 1, + assetOut: 0, + direction: .sell + ) + + Logger.shared.info("Quote: \(quote)") + } + + func testQuoteForSiriWestmintBuy() throws { + let quote = try fetchQuote( + for: KnowChainId.westmint, + assetIn: 1, + assetOut: 0, + direction: .buy, + amount: 1_000_000 + ) + + Logger.shared.info("Quote: \(quote)") + } + + func testFeeForWestmintSiriSellInNativeToken() throws { + let amountIn: BigUInt = 1_000_000_000 + + let quote = try fetchQuote( + for: KnowChainId.westmint, + assetIn: 1, + assetOut: 0, + direction: .sell, + amount: amountIn + ) + + let callArgs = AssetConversion.CallArgs( + assetIn: quote.assetIn, + amountIn: quote.amountIn, + assetOut: quote.assetOut, + amountOut: quote.amountOut, + receiver: AccountId.zeroAccountId(of: 32), + direction: .sell, + slippage: .percent(of: 1) + ) + + let fee = try fetchFee(for: callArgs, feeAssetId: .init(chainId: KnowChainId.westmint, assetId: 0)) + + Logger.shared.info("Max fee: \(String(fee.totalFee.targetAmount))") + } + + func testFeeForWestmintSiriSellInSiriToken() throws { + let amountIn: BigUInt = 1_000_000_000 + + let quote = try fetchQuote( + for: KnowChainId.westmint, + assetIn: 1, + assetOut: 0, + direction: .sell, + amount: amountIn + ) + + let callArgs = AssetConversion.CallArgs( + assetIn: quote.assetIn, + amountIn: quote.amountIn, + assetOut: quote.assetOut, + amountOut: quote.amountOut, + receiver: AccountId.zeroAccountId(of: 32), + direction: .sell, + slippage: .percent(of: 1) + ) + + let fee = try fetchFee(for: callArgs, feeAssetId: .init(chainId: KnowChainId.westmint, assetId: 1)) + + Logger.shared.info("Max fee: \(String(fee.totalFee.targetAmount))") + } + + private func performAvailableDirectionsFetch( + for chainId: ChainModel.Id, + assetId: AssetModel.Id? + ) throws -> [ChainAssetId: Set] { + let storageFacade = SubstrateStorageTestFacade() + let chainRegistry = ChainRegistryFacade.setupForIntegrationTest(with: storageFacade) + + guard + let chain = chainRegistry.getChain(for: chainId), + let connection = chainRegistry.getConnection(for: chainId), + let runtimeService = chainRegistry.getRuntimeProvider(for: chainId) else { + throw ChainRegistryError.noChain(chainId) + } + + let operationQueue = OperationQueue() + + let operationFactory = AssetHubSwapOperationFactory( + chain: chain, + runtimeService: runtimeService, + connection: connection, + operationQueue: operationQueue + ) + + if let assetId = assetId { + let chainAssetId = ChainAssetId(chainId: chainId, assetId: assetId) + let wrapper = operationFactory.availableDirectionsForAsset(chainAssetId) + + operationQueue.addOperations(wrapper.allOperations, waitUntilFinished: true) + + let directions = try wrapper.targetOperation.extractNoCancellableResultData() + + return [chainAssetId: directions] + } else { + let wrapper = operationFactory.availableDirections() + + operationQueue.addOperations(wrapper.allOperations, waitUntilFinished: true) + + return try wrapper.targetOperation.extractNoCancellableResultData() + } + } + + private func fetchQuote( + for chainId: ChainModel.Id, + assetIn: AssetModel.Id, + assetOut: AssetModel.Id, + direction: AssetConversion.Direction, + amount: BigUInt = 1_000_000_000_000 + ) throws -> AssetConversion.Quote { + let storageFacade = SubstrateStorageTestFacade() + let chainRegistry = ChainRegistryFacade.setupForIntegrationTest(with: storageFacade) + + guard + let chain = chainRegistry.getChain(for: chainId), + let connection = chainRegistry.getConnection(for: chainId), + let runtimeService = chainRegistry.getRuntimeProvider(for: chainId) else { + throw ChainRegistryError.noChain(chainId) + } + + let operationQueue = OperationQueue() + + let operationFactory = AssetHubSwapOperationFactory( + chain: chain, + runtimeService: runtimeService, + connection: connection, + operationQueue: operationQueue + ) + + let args = AssetConversion.QuoteArgs( + assetIn: .init(chainId: chainId, assetId: assetIn), + assetOut: .init(chainId: chainId, assetId: assetOut), + amount: amount, + direction: direction + ) + + let quoteWrapper = operationFactory.quote(for: args) + + operationQueue.addOperations(quoteWrapper.allOperations, waitUntilFinished: true) + + return try quoteWrapper.targetOperation.extractNoCancellableResultData() + } + + private func fetchFee(for args: AssetConversion.CallArgs, feeAssetId: ChainAssetId) throws -> AssetConversion.FeeModel { + let storageFacade = SubstrateStorageTestFacade() + let chainRegistry = ChainRegistryFacade.setupForIntegrationTest(with: storageFacade) + + let chainId = args.assetIn.chainId + + guard + let chain = chainRegistry.getChain(for: chainId), + let asset = chain.asset(for: feeAssetId.assetId) else { + throw CommonError.dataCorruption + } + + let feeAsset = ChainAsset(chain: chain, asset: asset) + + let wallet = AccountGenerator.generateMetaAccount(generatingChainAccounts: 1) + + let operationQueue = OperationQueue() + + let feeService = AssetHubFeeService( + wallet: wallet, + chainRegistry: chainRegistry, + operationQueue: operationQueue + ) + + var feeResult: AssetConversion.FeeResult? + + let expectation = XCTestExpectation() + + feeService.calculate(in: feeAsset, callArgs: args, runCompletionIn: .main) { result in + feeResult = result + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 600) + + switch feeResult { + case let .success(fee): + return fee + case let .failure(error): + throw error + case .none: + throw CommonError.undefined + } + } +} diff --git a/novawalletIntegrationTests/AutocompounDelegateStakeTests.swift b/novawalletIntegrationTests/AutocompounDelegateStakeTests.swift index 270783129a..036a581138 100644 --- a/novawalletIntegrationTests/AutocompounDelegateStakeTests.swift +++ b/novawalletIntegrationTests/AutocompounDelegateStakeTests.swift @@ -199,6 +199,7 @@ class AutocompounDelegateStakeTests: XCTestCase { cryptoType: .sr25519, walletType: .watchOnly, runtimeRegistry: runtimeProvider, + extensions: DefaultExtrinsicExtension.extensions(), engine: connection, operationManager: OperationManager(operationQueue: operationQueue) ) @@ -269,6 +270,7 @@ class AutocompounDelegateStakeTests: XCTestCase { cryptoType: .sr25519, walletType: .watchOnly, runtimeRegistry: runtimeProvider, + extensions: DefaultExtrinsicExtension.extensions(), engine: connection, operationManager: OperationManager(operationQueue: OperationQueue()) ) diff --git a/novawalletIntegrationTests/ExtrinsicServiceTests.swift b/novawalletIntegrationTests/ExtrinsicServiceTests.swift index b061e43d7f..e9983a877c 100644 --- a/novawalletIntegrationTests/ExtrinsicServiceTests.swift +++ b/novawalletIntegrationTests/ExtrinsicServiceTests.swift @@ -57,6 +57,7 @@ class ExtrinsicServiceTests: XCTestCase { cryptoType: .sr25519, walletType: .secrets, runtimeRegistry: runtimeService, + extensions: DefaultExtrinsicExtension.extensions(), engine: connection, operationManager: OperationManagerFacade.sharedManager ) @@ -102,6 +103,7 @@ class ExtrinsicServiceTests: XCTestCase { cryptoType: .sr25519, walletType: .secrets, runtimeRegistry: runtimeService, + extensions: DefaultExtrinsicExtension.extensions(), engine: connection, operationManager: OperationManagerFacade.sharedManager ) diff --git a/novawalletIntegrationTests/XcmTransfersFeeTests.swift b/novawalletIntegrationTests/XcmTransfersFeeTests.swift index 5dd3159761..6e955893dd 100644 --- a/novawalletIntegrationTests/XcmTransfersFeeTests.swift +++ b/novawalletIntegrationTests/XcmTransfersFeeTests.swift @@ -179,7 +179,7 @@ class XcmTransfersFeeTests: XCTestCase { operationQueue.addOperations(wrapper.allOperations, waitUntilFinished: false) - _ = semaphore.wait(timeout: .now() + .seconds(60)) + _ = semaphore.wait(timeout: .now() + .seconds(600)) switch partiesResult { case let .success(parties): @@ -236,7 +236,7 @@ class XcmTransfersFeeTests: XCTestCase { } } - _ = semaphore.wait(timeout: .now() + .seconds(60)) + _ = semaphore.wait(timeout: .now() + .seconds(600)) switch feeResult { case let .success(parties): diff --git a/novawalletTests/Helper/AssetTransactionGenerator.swift b/novawalletTests/Helper/AssetTransactionGenerator.swift index 405a430603..f96d451bb0 100644 --- a/novawalletTests/Helper/AssetTransactionGenerator.swift +++ b/novawalletTests/Helper/AssetTransactionGenerator.swift @@ -22,9 +22,11 @@ enum AssetTransactionGenerator { txHash: hash, timestamp: Int64(Date().timeIntervalSince1970), fee: nil, + feeAssetId: nil, blockNumber: 100, txIndex: 0, callPath: .transfer, - call: nil + call: nil, + swap: nil ) } } diff --git a/novawalletTests/Mocks/DataProviders/ExtrinsicServiceFactoryStub.swift b/novawalletTests/Mocks/DataProviders/ExtrinsicServiceFactoryStub.swift index deab622569..82406ac6f5 100644 --- a/novawalletTests/Mocks/DataProviders/ExtrinsicServiceFactoryStub.swift +++ b/novawalletTests/Mocks/DataProviders/ExtrinsicServiceFactoryStub.swift @@ -1,5 +1,6 @@ import Foundation @testable import novawallet +import SubstrateSdk final class ExtrinsicServiceFactoryStub: ExtrinsicServiceFactoryProtocol { let extrinsicService: ExtrinsicServiceProtocol @@ -15,14 +16,16 @@ final class ExtrinsicServiceFactoryStub: ExtrinsicServiceFactoryProtocol { func createService( account: ChainAccountResponse, - chain: ChainModel + chain: ChainModel, + extensions: [ExtrinsicExtension] ) -> ExtrinsicServiceProtocol { extrinsicService } func createOperationFactory( account: ChainAccountResponse, - chain: ChainModel + chain: ChainModel, + extensions: [ExtrinsicExtension] ) -> ExtrinsicOperationFactoryProtocol { extrinsicOperationFactory } diff --git a/novawalletTests/Mocks/ModuleMocks.swift b/novawalletTests/Mocks/ModuleMocks.swift index 5883da179f..4f02b59996 100644 --- a/novawalletTests/Mocks/ModuleMocks.swift +++ b/novawalletTests/Mocks/ModuleMocks.swift @@ -17472,16 +17472,46 @@ import Cuckoo - func send() { + func repeatOperation() { - return cuckoo_manager.call("send()", + return cuckoo_manager.call("repeatOperation()", parameters: (), escapingParameters: (), superclassCall: Cuckoo.MockManager.crashOnProtocolSuperclassCall() , - defaultCall: __defaultImplStub!.send()) + defaultCall: __defaultImplStub!.repeatOperation()) + + } + + + + func showRateInfo() { + + return cuckoo_manager.call("showRateInfo()", + parameters: (), + escapingParameters: (), + superclassCall: + + Cuckoo.MockManager.crashOnProtocolSuperclassCall() + , + defaultCall: __defaultImplStub!.showRateInfo()) + + } + + + + func showNetworkFeeInfo() { + + return cuckoo_manager.call("showNetworkFeeInfo()", + parameters: (), + escapingParameters: (), + superclassCall: + + Cuckoo.MockManager.crashOnProtocolSuperclassCall() + , + defaultCall: __defaultImplStub!.showNetworkFeeInfo()) } @@ -17514,9 +17544,19 @@ import Cuckoo return .init(stub: cuckoo_manager.createStub(for: MockOperationDetailsPresenterProtocol.self, method: "showOperationActions()", parameterMatchers: matchers)) } - func send() -> Cuckoo.ProtocolStubNoReturnFunction<()> { + func repeatOperation() -> Cuckoo.ProtocolStubNoReturnFunction<()> { let matchers: [Cuckoo.ParameterMatcher] = [] - return .init(stub: cuckoo_manager.createStub(for: MockOperationDetailsPresenterProtocol.self, method: "send()", parameterMatchers: matchers)) + return .init(stub: cuckoo_manager.createStub(for: MockOperationDetailsPresenterProtocol.self, method: "repeatOperation()", parameterMatchers: matchers)) + } + + func showRateInfo() -> Cuckoo.ProtocolStubNoReturnFunction<()> { + let matchers: [Cuckoo.ParameterMatcher] = [] + return .init(stub: cuckoo_manager.createStub(for: MockOperationDetailsPresenterProtocol.self, method: "showRateInfo()", parameterMatchers: matchers)) + } + + func showNetworkFeeInfo() -> Cuckoo.ProtocolStubNoReturnFunction<()> { + let matchers: [Cuckoo.ParameterMatcher] = [] + return .init(stub: cuckoo_manager.createStub(for: MockOperationDetailsPresenterProtocol.self, method: "showNetworkFeeInfo()", parameterMatchers: matchers)) } } @@ -17560,9 +17600,21 @@ import Cuckoo } @discardableResult - func send() -> Cuckoo.__DoNotUse<(), Void> { + func repeatOperation() -> Cuckoo.__DoNotUse<(), Void> { + let matchers: [Cuckoo.ParameterMatcher] = [] + return cuckoo_manager.verify("repeatOperation()", callMatcher: callMatcher, parameterMatchers: matchers, sourceLocation: sourceLocation) + } + + @discardableResult + func showRateInfo() -> Cuckoo.__DoNotUse<(), Void> { let matchers: [Cuckoo.ParameterMatcher] = [] - return cuckoo_manager.verify("send()", callMatcher: callMatcher, parameterMatchers: matchers, sourceLocation: sourceLocation) + return cuckoo_manager.verify("showRateInfo()", callMatcher: callMatcher, parameterMatchers: matchers, sourceLocation: sourceLocation) + } + + @discardableResult + func showNetworkFeeInfo() -> Cuckoo.__DoNotUse<(), Void> { + let matchers: [Cuckoo.ParameterMatcher] = [] + return cuckoo_manager.verify("showNetworkFeeInfo()", callMatcher: callMatcher, parameterMatchers: matchers, sourceLocation: sourceLocation) } } @@ -17600,7 +17652,19 @@ import Cuckoo - func send() { + func repeatOperation() { + return DefaultValueRegistry.defaultValue(for: (Void).self) + } + + + + func showRateInfo() { + return DefaultValueRegistry.defaultValue(for: (Void).self) + } + + + + func showNetworkFeeInfo() { return DefaultValueRegistry.defaultValue(for: (Void).self) } @@ -17834,6 +17898,21 @@ import Cuckoo + func showSwapSetup(from: OperationDetailsViewProtocol?, state: SwapSetupInitState) { + + return cuckoo_manager.call("showSwapSetup(from: OperationDetailsViewProtocol?, state: SwapSetupInitState)", + parameters: (from, state), + escapingParameters: (from, state), + superclassCall: + + Cuckoo.MockManager.crashOnProtocolSuperclassCall() + , + defaultCall: __defaultImplStub!.showSwapSetup(from: from, state: state)) + + } + + + func present(message: String?, title: String?, closeAction: String?, from view: ControllerBackedProtocol?) { return cuckoo_manager.call("present(message: String?, title: String?, closeAction: String?, from: ControllerBackedProtocol?)", @@ -17876,6 +17955,11 @@ import Cuckoo return .init(stub: cuckoo_manager.createStub(for: MockOperationDetailsWireframeProtocol.self, method: "showSend(from: OperationDetailsViewProtocol?, displayAddress: DisplayAddress, chainAsset: ChainAsset)", parameterMatchers: matchers)) } + func showSwapSetup(from: M1, state: M2) -> Cuckoo.ProtocolStubNoReturnFunction<(OperationDetailsViewProtocol?, SwapSetupInitState)> where M1.OptionalMatchedType == OperationDetailsViewProtocol, M2.MatchedType == SwapSetupInitState { + let matchers: [Cuckoo.ParameterMatcher<(OperationDetailsViewProtocol?, SwapSetupInitState)>] = [wrap(matchable: from) { $0.0 }, wrap(matchable: state) { $0.1 }] + return .init(stub: cuckoo_manager.createStub(for: MockOperationDetailsWireframeProtocol.self, method: "showSwapSetup(from: OperationDetailsViewProtocol?, state: SwapSetupInitState)", parameterMatchers: matchers)) + } + func present(message: M1, title: M2, closeAction: M3, from view: M4) -> Cuckoo.ProtocolStubNoReturnFunction<(String?, String?, String?, ControllerBackedProtocol?)> where M1.OptionalMatchedType == String, M2.OptionalMatchedType == String, M3.OptionalMatchedType == String, M4.OptionalMatchedType == ControllerBackedProtocol { let matchers: [Cuckoo.ParameterMatcher<(String?, String?, String?, ControllerBackedProtocol?)>] = [wrap(matchable: message) { $0.0 }, wrap(matchable: title) { $0.1 }, wrap(matchable: closeAction) { $0.2 }, wrap(matchable: view) { $0.3 }] return .init(stub: cuckoo_manager.createStub(for: MockOperationDetailsWireframeProtocol.self, method: "present(message: String?, title: String?, closeAction: String?, from: ControllerBackedProtocol?)", parameterMatchers: matchers)) @@ -17908,6 +17992,12 @@ import Cuckoo return cuckoo_manager.verify("showSend(from: OperationDetailsViewProtocol?, displayAddress: DisplayAddress, chainAsset: ChainAsset)", callMatcher: callMatcher, parameterMatchers: matchers, sourceLocation: sourceLocation) } + @discardableResult + func showSwapSetup(from: M1, state: M2) -> Cuckoo.__DoNotUse<(OperationDetailsViewProtocol?, SwapSetupInitState), Void> where M1.OptionalMatchedType == OperationDetailsViewProtocol, M2.MatchedType == SwapSetupInitState { + let matchers: [Cuckoo.ParameterMatcher<(OperationDetailsViewProtocol?, SwapSetupInitState)>] = [wrap(matchable: from) { $0.0 }, wrap(matchable: state) { $0.1 }] + return cuckoo_manager.verify("showSwapSetup(from: OperationDetailsViewProtocol?, state: SwapSetupInitState)", callMatcher: callMatcher, parameterMatchers: matchers, sourceLocation: sourceLocation) + } + @discardableResult func present(message: M1, title: M2, closeAction: M3, from view: M4) -> Cuckoo.__DoNotUse<(String?, String?, String?, ControllerBackedProtocol?), Void> where M1.OptionalMatchedType == String, M2.OptionalMatchedType == String, M3.OptionalMatchedType == String, M4.OptionalMatchedType == ControllerBackedProtocol { let matchers: [Cuckoo.ParameterMatcher<(String?, String?, String?, ControllerBackedProtocol?)>] = [wrap(matchable: message) { $0.0 }, wrap(matchable: title) { $0.1 }, wrap(matchable: closeAction) { $0.2 }, wrap(matchable: view) { $0.3 }] @@ -17937,6 +18027,12 @@ import Cuckoo + func showSwapSetup(from: OperationDetailsViewProtocol?, state: SwapSetupInitState) { + return DefaultValueRegistry.defaultValue(for: (Void).self) + } + + + func present(message: String?, title: String?, closeAction: String?, from view: ControllerBackedProtocol?) { return DefaultValueRegistry.defaultValue(for: (Void).self) } diff --git a/novawalletTests/Modules/Swaps/SwapsValidationTests.swift b/novawalletTests/Modules/Swaps/SwapsValidationTests.swift new file mode 100644 index 0000000000..e09e28e3db --- /dev/null +++ b/novawalletTests/Modules/Swaps/SwapsValidationTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import novawallet +import SoraKeystore +import Cuckoo +import BigInt + +final class SwapsValidationTests: XCTestCase { + private func amountInPlank(_ amount: Decimal, _ chainAsset: ChainAsset) -> BigUInt { + amount.toSubstrateAmount(precision: chainAsset.assetDisplayInfo.assetPrecision) ?? 0 + } + + func testCalculatedFeeWithoutED() throws { + let chain = ChainModelGenerator.generateChain(generatingAssets: 3, addressPrefix: 42) + let utilityChainAsset = ChainAsset(chain: chain, asset: chain.assets.first(where: { $0.assetId == 0 })!) + let payChainAsset = ChainAsset(chain: chain, asset: chain.assets.first(where: { $0.assetId == 2 })!) + let feeChainAsset = payChainAsset + let accountId = try WestendStub.address.toAccountId() + + let freeBalance = amountInPlank(50, payChainAsset) + let payAssetBalance = AssetBalance(chainAssetId: payChainAsset.chainAssetId, + accountId: accountId, + freeInPlank: freeBalance, + reservedInPlank: 0, + frozenInPlank: 0, + blocked: false) + let existentialDeposit = amountInPlank(1, utilityChainAsset) + let fee = amountInPlank(0.1, payChainAsset) + let existentialDepositInFeeToken = amountInPlank(0.01, payChainAsset) + + let swapMax = SwapMaxModel( + payChainAsset: payChainAsset, + feeChainAsset: feeChainAsset, + balance: payAssetBalance, + feeModel: .init( + totalFee: .init( + targetAmount: fee + existentialDepositInFeeToken, + nativeAmount: (fee + existentialDeposit) / 100 + ), + networkFee: .init( + targetAmount: fee, + nativeAmount: fee / 100 + ) + ), + payAssetExistense: nil, + receiveAssetExistense: nil, + accountInfo: nil + ) + + let result = swapMax.calculate() + + XCTAssertEqual(result, 49.89) + + } + + func testCalculatedFeeWithED() throws { + let chain = ChainModelGenerator.generateChain(generatingAssets: 3, addressPrefix: 42) + let utilityChainAsset = ChainAsset(chain: chain, asset: chain.assets.first(where: { $0.assetId == 0 })!) + let payChainAsset = ChainAsset(chain: chain, asset: chain.assets.first(where: { $0.assetId == 2 })!) + let feeChainAsset = utilityChainAsset + let accountId = try WestendStub.address.toAccountId() + + let freeBalance = amountInPlank(50, payChainAsset) + let payAssetBalance = AssetBalance(chainAssetId: payChainAsset.chainAssetId, + accountId: accountId, + freeInPlank: freeBalance, + reservedInPlank: 0, + frozenInPlank: 0, + blocked: false) + + let fee = amountInPlank(0.1, payChainAsset) + + let params = SwapMaxModel( + payChainAsset: payChainAsset, + feeChainAsset: feeChainAsset, + balance: payAssetBalance, + feeModel: .init( + totalFee: .init( + targetAmount: fee, + nativeAmount: fee + ), + networkFee: .init( + targetAmount: fee, + nativeAmount: fee + ) + ), + payAssetExistense: nil, + receiveAssetExistense: nil, + accountInfo: nil + ) + + let result = params.calculate() + + XCTAssertEqual(result, 50) + + } +} diff --git a/novawalletTests/Modules/WalletOperationDetails/ExtrinsicDetailsTests.swift b/novawalletTests/Modules/WalletOperationDetails/ExtrinsicDetailsTests.swift index 7ea343c2a5..2e3d5e07d4 100644 --- a/novawalletTests/Modules/WalletOperationDetails/ExtrinsicDetailsTests.swift +++ b/novawalletTests/Modules/WalletOperationDetails/ExtrinsicDetailsTests.swift @@ -62,14 +62,12 @@ class OperationDetailsTests: XCTestCase { operationDataProvider: operationDataProvider ) - let balanceViewModelFactory = BalanceViewModelFactory( - targetAssetInfo: chainAsset.assetDisplayInfo, + let balanceViewModelFactoryFacade = BalanceViewModelFactoryFacade( priceAssetInfoFactory: PriceAssetInfoFactory(currencyManager: CurrencyManagerStub()) ) let viewModelFactory = OperationDetailsViewModelFactory( - balanceViewModelFactory: balanceViewModelFactory, - feeViewModelFactory: nil + balanceViewModelFactoryFacade: balanceViewModelFactoryFacade ) let presenter = OperationDetailsPresenter(