From 8d6d6bdd8481a47ac864caeb4eac2a89e742277e Mon Sep 17 00:00:00 2001 From: Khushboo Mehta Date: Thu, 6 Jun 2024 16:05:31 +0200 Subject: [PATCH] feat(@desktop/wallet): Getting swap proposal fixes #14828 --- storybook/pages/SwapInputPanelPage.qml | 1 + storybook/pages/SwapModalPage.qml | 96 +++-- .../qmlTests/tests/tst_SwapInputPanel.qml | 1 + storybook/qmlTests/tests/tst_SwapModal.qml | 166 ++++++++- .../src/Models/SwapTransactionRoutes.qml | 144 ++++++++ storybook/src/Models/qmldir | 1 + ui/app/AppLayouts/Wallet/WalletLayout.qml | 2 + .../Wallet/controls/EditSlippagePanel.qml | 8 +- .../popups/swap/SwapInputParamsForm.qml | 35 ++ .../Wallet/popups/swap/SwapModal.qml | 223 +++++++----- .../Wallet/popups/swap/SwapModalAdaptor.qml | 336 ++++++++++-------- .../Wallet/popups/swap/SwapOutputData.qml | 26 ++ ui/app/AppLayouts/Wallet/popups/swap/qmldir | 1 + ui/app/AppLayouts/Wallet/stores/SwapStore.qml | 4 + ui/app/mainui/Popups.qml | 1 + ui/imports/utils/Constants.qml | 7 + 16 files changed, 765 insertions(+), 287 deletions(-) create mode 100644 storybook/src/Models/SwapTransactionRoutes.qml create mode 100644 ui/app/AppLayouts/Wallet/popups/swap/SwapOutputData.qml diff --git a/storybook/pages/SwapInputPanelPage.qml b/storybook/pages/SwapInputPanelPage.qml index 60fb12454..1797cef08 100644 --- a/storybook/pages/SwapInputPanelPage.qml +++ b/storybook/pages/SwapInputPanelPage.qml @@ -53,6 +53,7 @@ SplitView { } currencyStore: CurrenciesStore {} swapFormData: d.swapInputParamsForm + swapOutputData: SwapOutputData {} } } diff --git a/storybook/pages/SwapModalPage.qml b/storybook/pages/SwapModalPage.qml index 7cf8ce14b..edc6b6fe8 100644 --- a/storybook/pages/SwapModalPage.qml +++ b/storybook/pages/SwapModalPage.qml @@ -42,6 +42,8 @@ SplitView { } return selectedChain } + + readonly property SwapTransactionRoutes dummySwapTransactionRoutes: SwapTransactionRoutes{} } PopupBackground { @@ -71,6 +73,45 @@ SplitView { toTokenAmount: swapOutputAmount.text } + SwapModalAdaptor { + id: swapModalAdaptor + swapStore: SwapStore { + signal suggestedRoutesReady(var txRoutes) + readonly property var accounts: d.accountsModel + readonly property var flatNetworks: d.flatNetworksModel + readonly property bool areTestNetworksEnabled: areTestNetworksEnabledCheckbox.checked + + function fetchSuggestedRoutes(accountFrom, accountTo, amount, tokenFrom, tokenTo, + disabledFromChainIDs, disabledToChainIDs, preferredChainIDs, sendType, lockedInAmounts) { + console.debug("fetchSuggestedRoutes called >> accountFrom = ",accountFrom, " accountTo =", + accountTo, "amount = ",amount, " tokenFrom = ",tokenFrom, " tokenTo = ", tokenTo, + " disabledFromChainIDs = ",disabledFromChainIDs, " disabledToChainIDs = ",disabledToChainIDs, + " preferredChainIDs = ",preferredChainIDs, " sendType =", sendType, " lockedInAmounts = ",lockedInAmounts) + } + function authenticateAndTransfer(uuid, accountFrom, accountTo, tokenFrom, + tokenTo, sendType, tokenName, tokenIsOwnerToken, paths) { + console.debug("authenticateAndTransfer called >> uuid ", uuid, " accountFrom = ",accountFrom, " accountTo =", + accountTo, "tokenFrom = ",tokenFrom, " tokenTo = ",tokenTo, " sendType = ", sendType, + " tokenName = ", tokenName, " tokenIsOwnerToken = ", tokenIsOwnerToken, " paths = ", paths) + } + function getWei2Eth(wei, decimals) { + return wei/(10**decimals) + } + } + walletAssetsStore: WalletAssetsStore { + id: thisWalletAssetStore + walletTokensStore: TokensStore { + readonly property var plainTokensBySymbolModel: TokensBySymbolModel {} + getDisplayAssetsBelowBalanceThresholdDisplayAmount: () => 0 + } + readonly property var baseGroupedAccountAssetModel: GroupedAccountsAssetsModel {} + assetsWithFilteredBalances: thisWalletAssetStore.groupedAccountsAssetsModel + } + currencyStore: CurrenciesStore {} + swapFormData: swapInputForm + swapOutputData: SwapOutputData{} + } + Component { id: swapModal SwapModal { @@ -79,32 +120,7 @@ SplitView { closePolicy: Popup.CloseOnEscape destroyOnClose: true swapInputParamsForm: swapInputForm - swapAdaptor: SwapModalAdaptor { - swapProposalLoading: loadingCheckBox.checked - swapProposalReady: swapProposalReadyCheckBox.checked - swapStore: SwapStore { - readonly property var accounts: d.accountsModel - readonly property var flatNetworks: d.flatNetworksModel - readonly property bool areTestNetworksEnabled: areTestNetworksEnabledCheckbox.checked - - signal suggestedRoutesReady(var txRoutes) - - function fetchSuggestedRoutes(accountFrom, accountTo, amount, tokenFrom, tokenTo, - disabledFromChainIDs, disabledToChainIDs, preferredChainIDs, sendType, lockedInAmounts) {} - function authenticateAndTransfer(uuid, accountFrom, accountTo, - tokenFrom, tokenTo, sendType, tokenName, tokenIsOwnerToken, paths) {} - } - walletAssetsStore: WalletAssetsStore { - id: thisWalletAssetStore - walletTokensStore: TokensStore { - plainTokensBySymbolModel: TokensBySymbolModel {} - } - readonly property var baseGroupedAccountAssetModel: GroupedAccountsAssetsModel {} - assetsWithFilteredBalances: thisWalletAssetStore.groupedAccountsAssetsModel - } - currencyStore: CurrenciesStore {} - swapFormData: swapInputForm - } + swapAdaptor: swapModalAdaptor } } } @@ -187,23 +203,25 @@ SplitView { currentIndex: 1 } - StatusInput { - id: swapOutputAmount - Layout.preferredWidth: 100 - label: "Token amount to receive" - text: "100" + Button { + text: "emit no routes found event" + onClicked: { + swapModalAdaptor.swapStore.suggestedRoutesReady(d.dummySwapTransactionRoutes.txNoRoutes) + } } - CheckBox { - id: loadingCheckBox - text: "swap proposal loading" - checked: false + Button { + text: "emit no approval needed route" + onClicked: { + swapModalAdaptor.swapStore.suggestedRoutesReady(d.dummySwapTransactionRoutes.txHasRouteNoApproval) + } } - CheckBox { - id: swapProposalReadyCheckBox - text: "swap proposal ready" - checked: false + Button { + text: "emit approval needed route" + onClicked: { + swapModalAdaptor.swapStore.suggestedRoutesReady(d.dummySwapTransactionRoutes.txHasRoutesApprovalNeeded) + } } } } diff --git a/storybook/qmlTests/tests/tst_SwapInputPanel.qml b/storybook/qmlTests/tests/tst_SwapInputPanel.qml index d88f12be1..2eeeac213 100644 --- a/storybook/qmlTests/tests/tst_SwapInputPanel.qml +++ b/storybook/qmlTests/tests/tst_SwapInputPanel.qml @@ -38,6 +38,7 @@ Item { } currencyStore: CurrenciesStore {} swapFormData: SwapInputParamsForm {} + swapOutputData: SwapOutputData {} } } diff --git a/storybook/qmlTests/tests/tst_SwapModal.qml b/storybook/qmlTests/tests/tst_SwapModal.qml index fc8f654f5..1b8659f3f 100644 --- a/storybook/qmlTests/tests/tst_SwapModal.qml +++ b/storybook/qmlTests/tests/tst_SwapModal.qml @@ -1,4 +1,4 @@ -import QtQuick 2.15 +import QtQuick 2.15 import QtTest 1.15 import StatusQ 0.1 // See #10218 @@ -22,10 +22,18 @@ Item { width: 600 height: 400 + readonly property var dummySwapTransactionRoutes: SwapTransactionRoutes {} + readonly property var swapStore: SwapStore { + signal suggestedRoutesReady(var txRoutes) readonly property var accounts: WalletAccountsModel {} readonly property var flatNetworks: NetworksModel.flatNetworks readonly property bool areTestNetworksEnabled: true + function getWei2Eth(wei, decimals) { + return wei/(10**decimals) + } + function fetchSuggestedRoutes(accountFrom, accountTo, amount, tokenFrom, tokenTo, + disabledFromChainIDs, disabledToChainIDs, preferredChainIDs, sendType, lockedInAmounts) {} } readonly property var swapAdaptor: SwapModalAdaptor { @@ -34,12 +42,14 @@ Item { id: thisWalletAssetStore walletTokensStore: TokensStore { plainTokensBySymbolModel: TokensBySymbolModel {} + getDisplayAssetsBelowBalanceThresholdDisplayAmount: () => 0 } readonly property var baseGroupedAccountAssetModel: GroupedAccountsAssetsModel {} assetsWithFilteredBalances: thisWalletAssetStore.groupedAccountsAssetsModel } swapStore: root.swapStore swapFormData: root.swapFormData + swapOutputData: SwapOutputData{} } readonly property var swapFormData: SwapInputParamsForm {} @@ -58,8 +68,19 @@ Item { property SwapModal controlUnderTest: null + readonly property SignalSpy formValuesChanged: SignalSpy { + target: root.swapFormData + signalName: "formValuesChanged" + } + // helper functions ------------------------------------------------------------- + + function init() { + controlUnderTest = createTemporaryObject(componentUnderTest, root) + } + function launchAndVerfyModal() { + formValuesChanged.clear() verify(!!controlUnderTest) controlUnderTest.open() verify(!!controlUnderTest.opened) @@ -69,6 +90,7 @@ Item { verify(!!controlUnderTest) controlUnderTest.close() verify(!controlUnderTest.opened) + formValuesChanged.clear() } function getAndVerifyAccountsModalHeader() { @@ -85,11 +107,19 @@ Item { verify(!!accountsModalHeader.control.popup.opened) return accountsModalHeader } - // end helper functions ------------------------------------------------------------- - function init() { - controlUnderTest = createTemporaryObject(componentUnderTest, root) + function verifyLoadingAndNoErrorsState() { + // verify loading state was set and no errors currently + verify(!root.swapAdaptor.validSwapProposalReceived) + verify(root.swapAdaptor.swapProposalLoading) + compare(root.swapAdaptor.swapOutputData.fromTokenAmount, "0") + compare(root.swapAdaptor.swapOutputData.toTokenAmount, "0") + compare(root.swapAdaptor.swapOutputData.totalFees, 0) + compare(root.swapAdaptor.swapOutputData.bestRoutes, []) + compare(root.swapAdaptor.swapOutputData.approvalNeeded, false) + compare(root.swapAdaptor.swapOutputData.hasError, false) } + // end helper functions ------------------------------------------------------------- function test_floating_header_default_account() { verify(!!controlUnderTest) @@ -408,7 +438,7 @@ Item { verify(!editSlippagePanel.visible) // set swap proposal to ready and check state of the edit slippage buttons and max slippage values - root.swapAdaptor.swapProposalReady = true + root.swapAdaptor.validSwapProposalReceived = true compare(maxSlippageValue.text, "%1%".arg(0.5)) verify(editSlippageButton.visible) @@ -444,5 +474,131 @@ Item { verify(!!signButton) verify(signButton.enabled) } + + function test_modal_swap_proposal_setup() { + // by default the max slippage button should show no values and the edit button shouldnt be visible + + root.swapAdaptor.reset() + + // Launch popup + launchAndVerfyModal() + + const maxFeesText = findChild(controlUnderTest, "maxFeesText") + verify(!!maxFeesText) + + const maxFeesValue = findChild(controlUnderTest, "maxFeesValue") + verify(!!maxFeesValue) + + const signButton = findChild(controlUnderTest, "signButton") + verify(!!signButton) + + const errorTag = findChild(controlUnderTest, "errorTag") + verify(!!errorTag) + + // Check max fees values and sign button state when nothing is set + compare(maxFeesText.text, qsTr("Max fees:")) + compare(maxFeesValue.text, "--") + verify(!signButton.enabled) + verify(!errorTag.visible) + + // set input values in the form correctly + root.swapFormData.fromTokensKey = root.swapAdaptor.walletAssetsStore.walletTokensStore.plainTokensBySymbolModel.get(0).key + compare(formValuesChanged.count, 1) + root.swapFormData.toTokenKey = root.swapAdaptor.walletAssetsStore.walletTokensStore.plainTokensBySymbolModel.get(1).key + compare(formValuesChanged.count, 2) + root.swapFormData.fromTokenAmount = 10 + compare(formValuesChanged.count, 3) + root.swapFormData.selectedNetworkChainId = root.swapAdaptor.filteredFlatNetworksModel.get(0).chainId + compare(formValuesChanged.count, 4) + + // wait for fetchSuggestedRoutes function to be called + wait(1000) + + // verify loading state was set and no errors currently + verifyLoadingAndNoErrorsState() + + // emit event that no routes were found + root.swapStore.suggestedRoutesReady(root.dummySwapTransactionRoutes.txNoRoutes) + + // verify loading state was removed and that error was displayed + verify(!root.swapAdaptor.validSwapProposalReceived) + verify(!root.swapAdaptor.swapProposalLoading) + compare(root.swapAdaptor.swapOutputData.fromTokenAmount, "0") + compare(root.swapAdaptor.swapOutputData.toTokenAmount, "0") + compare(root.swapAdaptor.swapOutputData.totalFees, 0) + compare(root.swapAdaptor.swapOutputData.bestRoutes, []) + compare(root.swapAdaptor.swapOutputData.approvalNeeded, false) + compare(root.swapAdaptor.swapOutputData.hasError, true) + verify(errorTag.visible) + verify(errorTag.text, qsTr("An error has occured, please try again")) + verify(!signButton.enabled) + compare(signButton.text, qsTr("Swap")) + + // edit some params to retry swap + root.swapFormData.fromTokenAmount = 11 + compare(formValuesChanged.count, 5) + + // wait for fetchSuggestedRoutes function to be called + wait(1000) + + // verify loading state was set and no errors currently + verifyLoadingAndNoErrorsState() + + // emit event with route that needs no approval + let txRoutes = root.dummySwapTransactionRoutes.txHasRouteNoApproval + root.swapStore.suggestedRoutesReady(txRoutes) + + // verify loading state removed and data ius displayed as expected on the Modal + verify(root.swapAdaptor.validSwapProposalReceived) + verify(!root.swapAdaptor.swapProposalLoading) + compare(root.swapAdaptor.swapOutputData.fromTokenAmount, "0") + compare(root.swapAdaptor.swapOutputData.toTokenAmount, root.swapStore.getWei2Eth(txRoutes.amountToReceive, root.swapAdaptor.toToken.decimals).toString()) + + // calculation needed for total fees + let gasTimeEstimate = txRoutes.gasTimeEstimate + let totalTokenFeesInFiat = gasTimeEstimate.totalTokenFees * root.swapAdaptor.fromToken.marketDetails.currencyPrice.amount + let totalFees = root.swapAdaptor.currencyStore.getFiatValue(gasTimeEstimate.totalFeesInEth, Constants.ethToken) + totalTokenFeesInFiat + + compare(root.swapAdaptor.swapOutputData.totalFees, totalFees) + compare(root.swapAdaptor.swapOutputData.bestRoutes, txRoutes.suggestedRoutes) + compare(root.swapAdaptor.swapOutputData.approvalNeeded, false) + compare(root.swapAdaptor.swapOutputData.hasError, false) + verify(!errorTag.visible) + verify(signButton.enabled) + compare(signButton.text, qsTr("Swap")) + + // edit some params to retry swap + root.swapFormData.fromTokenAmount = 1 + compare(formValuesChanged.count, 6) + + // wait for fetchSuggestedRoutes function to be called + wait(1000) + + // verify loading state was set and no errors currently + verifyLoadingAndNoErrorsState() + + // emit event with route that needs no approval + let txRoutes2 = root.dummySwapTransactionRoutes.txHasRoutesApprovalNeeded + root.swapStore.suggestedRoutesReady(txRoutes2) + + // verify loading state removed and data ius displayed as expected on the Modal + verify(root.swapAdaptor.validSwapProposalReceived) + verify(!root.swapAdaptor.swapProposalLoading) + compare(root.swapAdaptor.swapOutputData.fromTokenAmount, "0") + compare(root.swapAdaptor.swapOutputData.toTokenAmount, root.swapStore.getWei2Eth(txRoutes2.amountToReceive, root.swapAdaptor.toToken.decimals).toString()) + + // calculation needed for total fees + gasTimeEstimate = txRoutes2.gasTimeEstimate + totalTokenFeesInFiat = gasTimeEstimate.totalTokenFees * root.swapAdaptor.fromToken.marketDetails.currencyPrice.amount + totalFees = root.swapAdaptor.currencyStore.getFiatValue(gasTimeEstimate.totalFeesInEth, Constants.ethToken) + totalTokenFeesInFiat + + compare(root.swapAdaptor.swapOutputData.totalFees, totalFees) + compare(root.swapAdaptor.swapOutputData.bestRoutes, txRoutes2.suggestedRoutes) + compare(root.swapAdaptor.swapOutputData.approvalNeeded, true) + compare(root.swapAdaptor.swapOutputData.hasError, false) + verify(!errorTag.visible) + verify(signButton.enabled) + compare(signButton.text, qsTr("Approve %1").arg(root.swapAdaptor.fromToken.symbol)) + } } } diff --git a/storybook/src/Models/SwapTransactionRoutes.qml b/storybook/src/Models/SwapTransactionRoutes.qml new file mode 100644 index 000000000..605835e0b --- /dev/null +++ b/storybook/src/Models/SwapTransactionRoutes.qml @@ -0,0 +1,144 @@ +import QtQuick 2.15 + +import StatusQ 0.1 +import StatusQ.Core 0.1 + +import utils 1.0 + +QtObject { + id: root + + property var txNoRoutes: ({ + suggestedRoutes: root.noRoutes, + gasTimeEstimate: { + totalFeesInEth:0.0, + totalTokenFees:0.0, + totalTime:0 + }, + amountToReceive:"0", + toNetworksModel:[], + error:"" + }) + + + property var txHasRouteNoApproval: ({ + suggestedRoutes: root.goodRouteNoApprovalNeeded, + gasTimeEstimate:{ + totalFeesInEth:0.0005032000000000001, + totalTokenFees:-0.004508663259772343, + totalTime:2 + }, + amountToReceive: 379295138519599728000, + toNetworksModel: root.toModel + }) + + property var txHasRoutesApprovalNeeded: ({ + suggestedRoutes: root.goodRouteApprovalNeeded, + gasTimeEstimate:{ + totalFeesInEth:0.0005032000000000001, + totalTokenFees:-0.004508663259772343, + totalTime:2 + }, + amountToReceive: 379295138519599728000, + toNetworksModel: root.toModel + }) + + property ListModel toModel: ListModel { + ListElement { + chainId: 420 + chainName: "Optimism" + iconUrl: "network/Network=Optimism" + amountOut: "3003845308235848343" + } + } + property ListModel goodRouteNoApprovalNeeded: ListModel { + function rowCount() { + return count + } + + Component.onCompleted: append(suggestesRoutes) + + property var suggestesRoutes: [ + { + route: { + bridgeName:"Paraswap", + fromNetwork: NetworksModel.flatNetworks.get(1), + toNetwork: NetworksModel.flatNetworks.get(1), + maxAmountIn:"22562169837824631", + amountIn:"100000000000000", + amountOut:"379295138519599728", + gasAmount:169300, + gasFees:{ + gasPrice:0.061734012, + baseFee:0.055187939, + maxPriorityFeePerGas:0.001, + maxFeePerGasL:0.059980417, + maxFeePerGasM:0.060071775, + maxFeePerGasH:0.110375878, + l1GasFee:318800.0, + eip1559Enabled:true + }, + tokenFees:0.0, + bonderFees:"0x0", + cost:1211911824.038662, + estimatedTime:3, + amountInLocked:false, + isFirstSimpleTx:true, + isFirstBridgeTx:true, + approvalRequired:false, + approvalGasFees:0.0, + approvalAmountRequired:"0", + approvalContractAddress:"0x216b4b4ba9f3e719726886d34a177484278bfcae" + } + } + ] + } + property ListModel goodRouteApprovalNeeded: ListModel { + function rowCount() { + return count + } + + Component.onCompleted: append(suggestesRoutes) + + property var suggestesRoutes: [ + { + route: { + bridgeName:"Paraswap", + fromNetwork: NetworksModel.flatNetworks.get(1), + toNetwork: NetworksModel.flatNetworks.get(1), + maxAmountIn:"22562169837824631", + amountIn:"100000000000000", + amountOut:"379295138519599728", + gasAmount:169300, + gasFees:{ + gasPrice:0.061734012, + baseFee:0.055187939, + maxPriorityFeePerGas:0.001, + maxFeePerGasL:0.059980417, + maxFeePerGasM:0.060071775, + maxFeePerGasH:0.110375878, + l1GasFee:318800.0, + eip1559Enabled:true + }, + tokenFees:0.0, + bonderFees:"0x0", + cost:1211911824.038662, + estimatedTime:3, + amountInLocked:false, + isFirstSimpleTx:true, + isFirstBridgeTx:true, + approvalRequired:true, + approvalGasFees:0.0, + approvalAmountRequired:"0", + approvalContractAddress:"0x216b4b4ba9f3e719726886d34a177484278bfcae" + } + } + ] + } + + property ListModel noRoutes: ListModel { + function rowCount() { + return count + } + } +} diff --git a/storybook/src/Models/qmldir b/storybook/src/Models/qmldir index 95f792969..8ed029181 100644 --- a/storybook/src/Models/qmldir +++ b/storybook/src/Models/qmldir @@ -23,6 +23,7 @@ GroupedAccountsAssetsModel 1.0 GroupedAccountsAssetsModel.qml TokensBySymbolModel 1.0 TokensBySymbolModel.qml CommunitiesModel 1.0 CommunitiesModel.qml OnRampProvidersModel 1.0 OnRampProvidersModel.qml +SwapTransactionRoutes 1.0 SwapTransactionRoutes.qml singleton ModelsData 1.0 ModelsData.qml singleton NetworksModel 1.0 NetworksModel.qml diff --git a/ui/app/AppLayouts/Wallet/WalletLayout.qml b/ui/app/AppLayouts/Wallet/WalletLayout.qml index 6e1a58500..f0d1d81d2 100644 --- a/ui/app/AppLayouts/Wallet/WalletLayout.qml +++ b/ui/app/AppLayouts/Wallet/WalletLayout.qml @@ -219,6 +219,7 @@ Item { d.swapFormData.selectedAccountIndex = d.selectedAccountIndex d.swapFormData.selectedNetworkChainId = StatusQUtils.ModelUtils.getByKey(RootStore.filteredFlatModel, "layer", 1, "chainId") d.swapFormData.fromTokensKey = tokensKey + d.swapFormData.toTokenKey = RootStore.areTestNetworksEnabled ? Constants.swap.testStatusTokenKey : Constants.swap.mainnetStatusTokenKey Global.openSwapModalRequested(d.swapFormData) } } @@ -339,6 +340,7 @@ Item { if(!!walletStore.currentViewedHoldingTokensKey && walletStore.currentViewedHoldingType === Constants.TokenType.ERC20) { d.swapFormData.fromTokensKey = walletStore.currentViewedHoldingTokensKey } + d.swapFormData.toTokenKey = RootStore.areTestNetworksEnabled ? Constants.swap.testStatusTokenKey : Constants.swap.mainnetStatusTokenKey Global.openSwapModalRequested(d.swapFormData) } } diff --git a/ui/app/AppLayouts/Wallet/controls/EditSlippagePanel.qml b/ui/app/AppLayouts/Wallet/controls/EditSlippagePanel.qml index b2fa8352a..6ea9ef65f 100644 --- a/ui/app/AppLayouts/Wallet/controls/EditSlippagePanel.qml +++ b/ui/app/AppLayouts/Wallet/controls/EditSlippagePanel.qml @@ -7,6 +7,7 @@ import StatusQ.Popups 0.1 import StatusQ.Controls 0.1 import StatusQ.Components 0.1 import StatusQ.Core.Theme 0.1 +import StatusQ.Core.Utils 0.1 as SQUtils import shared.controls 1.0 @@ -94,8 +95,11 @@ Control { } StatusTextWithLoadingState { text: { - let amount = parseFloat(root.toTokenAmount) - let percentageAmount = (amount - ((amount/100) * slippageSelector.value)) + let amount = !!root.toTokenAmount ? SQUtils.AmountsArithmetic.fromString(root.toTokenAmount) : NaN + let percentageAmount = 0 + if(!Number.isNaN(amount)) { + percentageAmount = (amount - ((amount/100) * slippageSelector.value)) + } return ("%1 %2").arg(LocaleUtils.numberToLocaleString(percentageAmount)).arg(d.selectedToTokenSymbol) } font.pixelSize: 13 diff --git a/ui/app/AppLayouts/Wallet/popups/swap/SwapInputParamsForm.qml b/ui/app/AppLayouts/Wallet/popups/swap/SwapInputParamsForm.qml index 68b192e40..420220b8c 100644 --- a/ui/app/AppLayouts/Wallet/popups/swap/SwapInputParamsForm.qml +++ b/ui/app/AppLayouts/Wallet/popups/swap/SwapInputParamsForm.qml @@ -1,9 +1,14 @@ import QtQml 2.15 +import StatusQ.Core.Utils 0.1 as SQUtils + /* This is used so that there is an easy way to fill in the data needed to launch the Swap Modal with pre-filled requisites. */ QtObject { id: root + + signal formValuesChanged() + property int selectedAccountIndex: 0 property int selectedNetworkChainId: -1 property string fromTokensKey: "" @@ -11,4 +16,34 @@ QtObject { property string toTokenKey: "" property string toTokenAmount: "0" property double selectedSlippage: 0.5 + + onSelectedAccountIndexChanged: root.formValuesChanged() + onSelectedNetworkChainIdChanged: root.formValuesChanged() + onFromTokensKeyChanged: root.formValuesChanged() + onFromTokenAmountChanged: root.formValuesChanged() + onToTokenKeyChanged: root.formValuesChanged() + onToTokenAmountChanged: root.formValuesChanged() + + function resetFormData() { + selectedAccountIndex = 0 + selectedNetworkChainId = -1 + fromTokensKey = "" + fromTokenAmount = "0" + toTokenKey = "" + toTokenAmount = "0" + selectedSlippage = 0.5 + } + + function isFormFilledCorrectly() { + return root.selectedAccountIndex >= 0 && + root.selectedNetworkChainId !== -1 && + !!root.fromTokensKey && !!root.toTokenKey && + ((!!root.fromTokenAmount && + !isNaN(SQUtils.AmountsArithmetic.fromString(root.fromTokenAmount)) && + SQUtils.AmountsArithmetic.fromString(root.fromTokenAmount) > 0) || + (!!root.toTokenAmount && + !isNaN(SQUtils.AmountsArithmetic.fromString(root.toTokenAmount)) && + SQUtils.AmountsArithmetic.fromString(root.toTokenAmount) > 0 )) && + root.selectedSlippage > 0 + } } diff --git a/ui/app/AppLayouts/Wallet/popups/swap/SwapModal.qml b/ui/app/AppLayouts/Wallet/popups/swap/SwapModal.qml index 187433e6a..41b4c736b 100644 --- a/ui/app/AppLayouts/Wallet/popups/swap/SwapModal.qml +++ b/ui/app/AppLayouts/Wallet/popups/swap/SwapModal.qml @@ -12,6 +12,7 @@ import StatusQ.Popups.Dialog 0.1 import StatusQ.Controls 0.1 import shared.popups.send.controls 1.0 +import shared.controls 1.0 import AppLayouts.Wallet.controls 1.0 @@ -32,10 +33,27 @@ StatusDialog { rightPadding: Style.current.xlPadding backgroundColor: Theme.palette.baseColor3 + QtObject { + id: d + property var fetchSuggestedRoutes: Backpressure.debounce(root, 1000, function() { + root.swapAdaptor.fetchSuggestedRoutes() + }) + } + + Connections { + target: root.swapInputParamsForm + function onFormValuesChanged() { + root.swapAdaptor.swapProposalLoading = true + d.fetchSuggestedRoutes() + } + } + Behavior on implicitHeight { NumberAnimation { duration: 1000; easing.type: Easing.OutExpo; alwaysRunToEnd: true} } + onClosed: root.swapAdaptor.reset() + header: AccountsModalHeader { anchors.top: parent.top anchors.topMargin: -height - 18 @@ -52,91 +70,101 @@ StatusDialog { } contentItem: ColumnLayout { - spacing: 5 + spacing: Style.current.padding clip: true - RowLayout { + // without this Column, the whole popup resizing when the network selector popup is clicked + Column { Layout.fillWidth: true - spacing: 12 - HeaderTitleText { - Layout.fillWidth: true - Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter - id: modalHeader - text: qsTr("Swap") - } - StatusBaseText { - Layout.alignment: Qt.AlignRight | Qt.AlignVCenter - text: qsTr("On:") - color: Theme.palette.baseColor1 - font.pixelSize: 13 - lineHeight: 38 - lineHeightMode: Text.FixedHeight - verticalAlignment: Text.AlignVCenter - } - // TODO: update this once https://github.com/status-im/status-desktop/issues/14780 is ready - NetworkFilter { - id: networkFilter - objectName: "networkFilter" - Layout.alignment: Qt.AlignVCenter - multiSelection: false - showRadioButtons: false - showTitle: false - flatNetworks: root.swapAdaptor.filteredFlatNetworksModel - onToggleNetwork: (network) => { - root.swapInputParamsForm.selectedNetworkChainId = network.chainId - } - Component.onCompleted: { - if(root.swapInputParamsForm.selectedNetworkChainId !== -1) - networkFilter.setChain(root.swapInputParamsForm.selectedNetworkChainId) + spacing: 0 + RowLayout { + width: parent.width + spacing: 12 + HeaderTitleText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter + id: modalHeader + text: qsTr("Swap") + } + StatusBaseText { + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + text: qsTr("On:") + color: Theme.palette.baseColor1 + font.pixelSize: 13 + lineHeight: 38 + lineHeightMode: Text.FixedHeight + verticalAlignment: Text.AlignVCenter + } + // TODO: update this once https://github.com/status-im/status-desktop/issues/14780 is ready + NetworkFilter { + id: networkFilter + objectName: "networkFilter" + Layout.alignment: Qt.AlignVCenter + multiSelection: false + showRadioButtons: false + showTitle: false + flatNetworks: root.swapAdaptor.filteredFlatNetworksModel + onToggleNetwork: (network) => { + root.swapInputParamsForm.selectedNetworkChainId = network.chainId + } + Component.onCompleted: { + if(root.swapInputParamsForm.selectedNetworkChainId !== -1) + networkFilter.setChain(root.swapInputParamsForm.selectedNetworkChainId) + } } } } // This is a temporary placeholder while each of the components are being added. - StatusBaseText { - topPadding: Style.current.padding - text: qsTr("This area is a temporary placeholder") - font.bold: true - } - StatusBaseText { - text: qsTr("Selected from token: %1").arg(swapInputParamsForm.fromTokensKey) - } - StatusBaseText { - text: qsTr("from token amount: %1").arg(swapInputParamsForm.fromTokenAmount) - } - StatusBaseText { - text: qsTr("Selected to token: %1").arg(swapInputParamsForm.toTokenKey) - } - StatusBaseText { - text: qsTr("to token amount: %1").arg(swapInputParamsForm.toTokenAmount) - } - StatusButton { - text: "Fetch Suggested Routes" - onClicked: { - swapAdaptor.fetchSuggestedRoutes() - } - } - StatusButton { - text: "Send Approve Tx" - onClicked: { - swapAdaptor.sendApproveTx() - } - } - StatusButton { - text: "Send Swap Tx" - onClicked: { - swapAdaptor.sendSwapTx() - } - } - StatusScrollView { + ColumnLayout { Layout.fillWidth: true - Layout.preferredHeight: 200 - - StatusTextArea { - text: { - let routes = SQUtils.ModelUtils.modelToArray(swapAdaptor.suggestedRoutes) - let routesString = JSON.stringify(routes, null, " ") - return qsTr("Suggested routes: \n%1").arg(routesString) + spacing: 0 + StatusBaseText { + text: "This area is a temporary placeholder" + font.bold: true + } + /* TODO: Will be swapped out under https://github.com/status-im/status-desktop/issues/14825 + Will also handle that if one input is being edited the other one should be in loading state in that task */ + StatusBaseText { + text: qsTr("Selected from token: %1").arg(swapInputParamsForm.fromTokensKey) + } + StatusInput { + id: fromTokenAmountInput + Layout.fillWidth: true + text: swapInputParamsForm.fromTokenAmount + onTextChanged: { + swapInputParamsForm.fromTokenAmount = text + } + } + /* TODO: Will be swapped out under https://github.com/status-im/status-desktop/issues/14826 + Will also handle that if one input is being edited the other one should be in loading state in that task */ + StatusBaseText { + text: qsTr("Selected to token: %1").arg(swapInputParamsForm.toTokenKey) + } + StatusInput { + id: toTokenAmountInput + Layout.fillWidth: true + text: root.swapAdaptor.validSwapProposalReceived && root.swapAdaptor.toToken ? + root.swapAdaptor.formatCurrencyAmount(root.swapAdaptor.swapOutputData.toTokenAmount, + root.swapAdaptor.toToken.symbol, + {"minDecimals": root.swapAdaptor.toToken.decimals, + "stripTrailingZeroes": true, "noSymbol": true}) : + root.swapInputParamsForm.toTokenAmount + onTextChanged: { + if (!root.swapAdaptor.validSwapProposalReceived) { + swapInputParamsForm.toTokenAmount = text + } + } + /* TODO: keep this input as disabled until the work for adding a param to handle to + and from tokens inputed is supported by backend */ + input.edit.enabled: false + } + /* Needed only till sign after approval is implemented under + https://github.com/status-im/status-desktop/issues/14833 */ + StatusButton { + text: "Final Swap after Approval" + onClicked: { + swapAdaptor.sendSwapTx() } } } @@ -149,12 +177,20 @@ StatusDialog { Layout.topMargin: Style.current.padding visible: editSlippageButton.checked selectedToToken: root.swapAdaptor.toToken - toTokenAmount: root.swapInputParamsForm.toTokenAmount + toTokenAmount: root.swapAdaptor.swapOutputData.toTokenAmount loading: root.swapAdaptor.swapProposalLoading onSlippageValueChanged: { root.swapInputParamsForm.selectedSlippage = slippageValue } } + + ErrorTag { + objectName: "errorTag" + visible: root.swapAdaptor.swapOutputData.hasError + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Style.current.smallPadding + text: qsTr("An error has occured, please try again") + } } footer: StatusDialogFooter { @@ -199,13 +235,20 @@ StatusDialog { spacing: Style.current.bigPadding ColumnLayout { StatusBaseText { - text:qsTr("Max fees:") + objectName: "maxFeesText" + text: qsTr("Max fees:") color: Theme.palette.directColor5 font.pixelSize: 15 font.weight: Font.Medium } StatusTextWithLoadingState { - text: loading ? Constants.dummyText : "--" + objectName: "maxFeesValue" + text: loading ? Constants.dummyText : + root.swapAdaptor.validSwapProposalReceived ? + root.swapAdaptor.currencyStore.formatCurrencyAmount( + root.swapAdaptor.swapOutputData.totalFees, + root.swapAdaptor.currencyStore.currentCurrency) : + "--" customColor: Theme.palette.directColor4 font.pixelSize: 15 font.weight: Font.Medium @@ -214,12 +257,26 @@ StatusDialog { } StatusButton { objectName: "signButton" - /* TODO: there maybe a different icon shown here in case of approval of spending cap - needed TBD under https://github.com/status-im/status-desktop/issues/14833 */ icon.name: "password" - text: qsTr("Swap") + /* TODO: Handling the next step agter approval of spending cap TBD under + https://github.com/status-im/status-desktop/issues/14833 */ + text: root.swapAdaptor.validSwapProposalReceived && + root.swapAdaptor.swapOutputData.approvalNeeded ? + qsTr("Approve %1").arg(!!root.swapAdaptor.fromToken ? root.swapAdaptor.fromToken.symbol: "") : + qsTr("Swap") disabledColor: Theme.palette.directColor8 - enabled: root.swapAdaptor.swapProposalReady && editSlippagePanel.valid + enabled: root.swapAdaptor.validSwapProposalReceived && editSlippagePanel.valid + onClicked: { + if (root.swapAdaptor.validSwapProposalReceived ){ + if(root.swapAdaptor.swapOutputData.approvalNeeded) { + swapAdaptor.sendApproveTx() + } + else { + swapAdaptor.sendSwapTx() + } + close() + } + } } } } diff --git a/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml b/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml index e4aeb495c..adf66c4f4 100644 --- a/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml +++ b/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml @@ -16,10 +16,10 @@ QObject { required property WalletStore.WalletAssetsStore walletAssetsStore required property WalletStore.SwapStore swapStore required property SwapInputParamsForm swapFormData + required property SwapOutputData swapOutputData - /* TODO: link to the actually api to get swap proposal from backend under - https://github.com/status-im/status-desktop/issues/14828 */ - property bool swapProposalReady: false + // the below 2 properties holds the state of finding a swap proposal + property bool validSwapProposalReceived: false property bool swapProposalLoading: false property bool showCommunityTokens @@ -28,28 +28,6 @@ QObject { readonly property var fromToken: ModelUtils.getByKey(root.walletAssetsStore.walletTokensStore.plainTokensBySymbolModel, "key", root.swapFormData.fromTokensKey) readonly property var toToken: ModelUtils.getByKey(root.walletAssetsStore.walletTokensStore.plainTokensBySymbolModel, "key", root.swapFormData.toTokenKey) - readonly property alias suggestedRoutes: d.suggestedRoutes - - QtObject { - id: d - - property string uuid - // TODO: Remove these properties swap proposal is properly handled - property var suggestedRoutes - property string rawPaths - } - - Connections { - target: root.swapStore - function onSuggestedRoutesReady(txRoutes) { - root.swapProposalReady = txRoutes.suggestedRoutes.count > 0 - root.swapProposalLoading = false - - d.suggestedRoutes = txRoutes.suggestedRoutes - d.rawPaths = txRoutes.rawPaths - } - } - readonly property var nonWatchAccounts: SortFilterProxyModel { sourceModel: root.swapStore.accounts filters: ValueFilter { @@ -61,7 +39,7 @@ QObject { proxyRoles: [ FastExpressionRole { name: "accountBalance" - expression: __processAccountBalance(model.address) + expression: d.processAccountBalance(model.address) expectedRoles: ["address"] }, FastExpressionRole { @@ -76,6 +54,155 @@ QObject { filters: ValueFilter { roleName: "isTest"; value: root.swapStore.areTestNetworksEnabled } } + // Model prepared to provide filtered and sorted assets as per the advanced Settings in token management + readonly property var processedAssetsModel: SortFilterProxyModel { + property real displayAssetsBelowBalanceThresholdAmount: root.walletAssetsStore.walletTokensStore.getDisplayAssetsBelowBalanceThresholdDisplayAmount() + sourceModel: d.assetsWithFilteredBalances + proxyRoles: [ + FastExpressionRole { + name: "currentBalance" + expression: { + // FIXME recalc when selectedNetworkChainId changes + root.swapFormData.selectedNetworkChainId + return d.getTotalBalance(model.balances, model.decimals) + } + expectedRoles: ["balances", "decimals"] + }, + FastExpressionRole { + name: "currentCurrencyBalance" + expression: { + if (!!model.marketDetails) { + return model.currentBalance * model.marketDetails.currencyPrice.amount + } + return 0 + } + expectedRoles: ["marketDetails", "currentBalance"] + } + ] + filters: [ + FastExpressionFilter { + expression: { + root.walletAssetsStore.assetsController.revision + + if (!root.walletAssetsStore.assetsController.filterAcceptsSymbol(model.symbol)) // explicitely hidden + return false + if (!!model.communityId) + return root.showCommunityTokens + if (root.walletAssetsStore.walletTokensStore.displayAssetsBelowBalance) + return model.currentCurrencyBalance > processedAssetsModel.displayAssetsBelowBalanceThresholdAmount + return true + } + expectedRoles: ["symbol", "communityId", "currentCurrencyBalance"] + } + ] + // FIXME sort by assetsController instead, to have the sorting/order as in the main wallet view + } + + QtObject { + id: d + + property string uuid + + // Internal model filtering balances by the account selected in the AccountsModalHeader + readonly property SubmodelProxyModel assetsWithFilteredBalances: SubmodelProxyModel { + sourceModel: root.walletAssetsStore.groupedAccountAssetsModel + submodelRoleName: "balances" + delegateModel: SortFilterProxyModel { + sourceModel: submodel + + filters: [ + ValueFilter { + roleName: "chainId" + value: root.swapFormData.selectedNetworkChainId + enabled: root.swapFormData.selectedNetworkChainId !== -1 + }/*, + // TODO enable once AccountsModalHeader is reworked!! + ValueFilter { + roleName: "account" + value: root.selectedSenderAccount.address + }*/ + ] + } + } + + readonly property SubmodelProxyModel filteredBalancesModel: SubmodelProxyModel { + sourceModel: root.walletAssetsStore.baseGroupedAccountAssetModel + submodelRoleName: "balances" + delegateModel: SortFilterProxyModel { + sourceModel: joinModel + filters: ValueFilter { + roleName: "chainId" + value: root.swapFormData.selectedNetworkChainId + } + readonly property LeftJoinModel joinModel: LeftJoinModel { + leftModel: submodel + rightModel: root.swapStore.flatNetworks + + joinRole: "chainId" + } + } + } + + function processAccountBalance(address) { + let network = ModelUtils.getByKey(root.filteredFlatNetworksModel, "chainId", root.swapFormData.selectedNetworkChainId) + if(!!network) { + let balancesModel = ModelUtils.getByKey(filteredBalancesModel, "tokensKey", root.swapFormData.fromTokensKey, "balances") + let accountBalance = ModelUtils.getByKey(balancesModel, "account", address) + if(!accountBalance) { + return { + balance: "0", + iconUrl: network.iconUrl, + chainColor: network.chainColor} + } + return accountBalance + } + return null + } + + /* Internal function to calculate total balance */ + function getTotalBalance(balances, decimals, chainIds = [root.swapFormData.selectedNetworkChainId]) { + let totalBalance = 0 + for(let i=0; i