import QtQuick 2.15 import QtQuick.Layouts 1.15 import QtQml.Models 2.15 import utils 1.0 import StatusQ.Controls 0.1 import StatusQ.Core 0.1 import StatusQ.Core.Backpressure 0.1 import StatusQ.Core.Theme 0.1 import StatusQ.Core.Utils 0.1 as SQUtils import StatusQ.Popups.Dialog 0.1 import shared.popups.send.controls 1.0 import shared.controls 1.0 import AppLayouts.Wallet.controls 1.0 import AppLayouts.Wallet.panels 1.0 import AppLayouts.Wallet.popups.buy 1.0 StatusDialog { id: root /* This should be the only property which should be used to input parameters to the modal when being launched from elsewhere */ required property SwapInputParamsForm swapInputParamsForm required property SwapModalAdaptor swapAdaptor required property int loginType objectName: "swapModal" implicitWidth: 556 padding: 0 backgroundColor: Theme.palette.baseColor3 QtObject { id: d property var debounceFetchSuggestedRoutes: Backpressure.debounce(root, 1000, function() { root.swapAdaptor.fetchSuggestedRoutes(payPanel.rawValue) }) function fetchSuggestedRoutes() { if (root.swapInputParamsForm.isFormFilledCorrectly()) { root.swapAdaptor.swapProposalLoading = true } root.swapAdaptor.validSwapProposalReceived = false root.swapAdaptor.approvalPending = false root.swapAdaptor.approvalSuccessful = false root.swapAdaptor.swapOutputData.resetPathInfoAndError() debounceFetchSuggestedRoutes() } readonly property bool isError: root.swapAdaptor.errorMessage !== "" readonly property BuyCryptoParamsForm buyFormData: BuyCryptoParamsForm { selectedWalletAddress: root.swapInputParamsForm.selectedAccountAddress selectedNetworkChainId: root.swapInputParamsForm.selectedNetworkChainId selectedTokenKey: root.swapInputParamsForm.fromTokensKey } function addMetricsEvent(subEventName) { Global.addCentralizedMetricIfEnabled("swap", {subEvent: subEventName}) } } Connections { target: root.swapInputParamsForm function onFormValuesChanged() { d.fetchSuggestedRoutes() } // FIXME drop after SwapInputPanel uses ModelEntry to track the currently selected token // refresh the selected asset in payPanel when account/network changes function onSelectedAccountAddressChanged() { payPanel.reevaluateSelectedId() } function onSelectedNetworkChainIdChanged() { networkFilter.selection = [root.swapInputParamsForm.selectedNetworkChainId] payPanel.reevaluateSelectedId() } } // needed as the first time the value not loaded correctly without this Binding Binding { target: root.swapAdaptor property: "amountEnteredGreaterThanBalance" value: payPanel.amountEnteredGreaterThanBalance } Behavior on implicitHeight { NumberAnimation { duration: 1000; easing.type: Easing.OutExpo; alwaysRunToEnd: true} } onOpened: { payPanel.forceActiveFocus() d.addMetricsEvent("popup opened") } onClosed: { root.swapAdaptor.stopUpdatesForSuggestedRoute() root.swapAdaptor.reset() d.addMetricsEvent("popup closed") } header: Item { implicitHeight: selector.implicitHeight implicitWidth: selector.implicitWidth anchors.top: parent.top anchors.topMargin: -height - 18 AccountSelectorHeader { id: selector control.popup.width: 512 model: root.swapAdaptor.nonWatchAccounts selectedAddress: root.swapInputParamsForm.selectedAccountAddress onCurrentAccountAddressChanged: { if (currentAccountAddress !== "" && currentAccountAddress !== root.swapInputParamsForm.selectedAccountAddress) { root.swapInputParamsForm.selectedAccountAddress = currentAccountAddress } } control.popup.onClosed: payPanel.forceActiveFocus() } } StatusScrollView { anchors.fill: parent contentWidth: availableWidth topPadding: 0 bottomPadding: Style.current.xlPadding ColumnLayout { anchors.left: parent.left anchors.leftMargin: Style.current.halfPadding anchors.right: parent.right anchors.rightMargin: Style.current.halfPadding spacing: Style.current.padding clip: true // without this Column, the whole popup resizing when the network selector popup is clicked Column { Layout.fillWidth: true spacing: 0 RowLayout { width: parent.width spacing: 12 HeaderTitleText { Layout.fillWidth: true Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter 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 } NetworkFilter { id: networkFilter objectName: "networkFilter" Layout.alignment: Qt.AlignVCenter multiSelection: false showSelectionIndicator: false showTitle: false flatNetworks: root.swapAdaptor.filteredFlatNetworksModel selection: [root.swapInputParamsForm.selectedNetworkChainId] onSelectionChanged: { if (root.swapInputParamsForm.selectedNetworkChainId !== selection[0]) { root.swapInputParamsForm.selectedNetworkChainId = selection[0] if(!!root.swapAdaptor.fromToken && !!root.swapAdaptor.fromToken.addressPerChain) { let fromTokenAddressOnSelectedChain = SQUtils.ModelUtils.getByKey( root.swapAdaptor.fromToken.addressPerChain, "chainId", root.swapInputParamsForm.selectedNetworkChainId, "address") if(!fromTokenAddressOnSelectedChain) { // reset from token as it doesnt exist on selected network root.swapInputParamsForm.resetFromTokenValues() } } if(!!root.swapAdaptor.toToken && !!root.swapAdaptor.toToken.addressPerChain) { let toTokenAddressOnSelectedChain = SQUtils.ModelUtils.getByKey( root.swapAdaptor.toToken.addressPerChain, "chainId", root.swapInputParamsForm.selectedNetworkChainId, "address") if(!toTokenAddressOnSelectedChain) { // reset to token as it doesnt exist on selected network root.swapInputParamsForm.resetToTokenValues(false) } } } payPanel.forceActiveFocus() } } } } Item { Layout.fillWidth: true Layout.margins: 2 Layout.preferredHeight: payPanel.height + receivePanel.height + 4 SwapInputPanel { id: payPanel objectName: "payPanel" anchors { left: parent.left right: parent.right top: parent.top } currencyStore: root.swapAdaptor.currencyStore flatNetworksModel: root.swapAdaptor.swapStore.flatNetworks processedAssetsModel: root.swapAdaptor.walletAssetsStore.groupedAccountAssetsModel plainTokensBySymbolModel: root.swapAdaptor.walletAssetsStore.walletTokensStore.plainTokensBySymbolModel tokenKey: root.swapInputParamsForm.fromTokensKey tokenAmount: root.swapInputParamsForm.fromTokenAmount selectedNetworkChainId: root.swapInputParamsForm.selectedNetworkChainId selectedAccountAddress: root.swapInputParamsForm.selectedAccountAddress nonInteractiveTokensKey: receivePanel.selectedHoldingId swapSide: SwapInputPanel.SwapSide.Pay swapExchangeButtonWidth: swapExchangeButton.width bottomTextLoading: root.swapAdaptor.swapProposalLoading onSelectedHoldingIdChanged: root.swapInputParamsForm.fromTokensKey = selectedHoldingId onRawValueChanged: { if(root.swapInputParamsForm.fromTokensKey === selectedHoldingId) { const amount = !tokenAmount && value === 0 ? "" : SQUtils.AmountsArithmetic.div(SQUtils.AmountsArithmetic.fromString(rawValue), SQUtils.AmountsArithmetic.fromNumber(1, rawValueMultiplierIndex)).toString() root.swapInputParamsForm.fromTokenAmount = amount } } } SwapInputPanel { id: receivePanel objectName: "receivePanel" anchors { left: parent.left right: parent.right bottom: parent.bottom } currencyStore: root.swapAdaptor.currencyStore flatNetworksModel: root.swapAdaptor.swapStore.flatNetworks processedAssetsModel: root.swapAdaptor.walletAssetsStore.groupedAccountAssetsModel plainTokensBySymbolModel: root.swapAdaptor.walletAssetsStore.walletTokensStore.plainTokensBySymbolModel tokenKey: root.swapInputParamsForm.toTokenKey tokenAmount: root.swapAdaptor.validSwapProposalReceived && root.swapAdaptor.toToken ? root.swapAdaptor.swapOutputData.toTokenAmount: root.swapInputParamsForm.toTokenAmount selectedNetworkChainId: root.swapInputParamsForm.selectedNetworkChainId selectedAccountAddress: root.swapInputParamsForm.selectedAccountAddress nonInteractiveTokensKey: payPanel.selectedHoldingId swapSide: SwapInputPanel.SwapSide.Receive swapExchangeButtonWidth: swapExchangeButton.width mainInputLoading: root.swapAdaptor.swapProposalLoading bottomTextLoading: root.swapAdaptor.swapProposalLoading onSelectedHoldingIdChanged: root.swapInputParamsForm.toTokenKey = selectedHoldingId /* TODO: keep this input as disabled until the work for adding a param to handle to and from tokens inputed is supported by backend under https://github.com/status-im/status-desktop/issues/15095 */ interactive: false } SwapExchangeButton { id: swapExchangeButton objectName: "swapExchangeButton" anchors.centerIn: parent enabled: !!root.swapInputParamsForm.fromTokensKey || !!root.swapInputParamsForm.toTokenKey onClicked: { const tempPayToken = root.swapInputParamsForm.fromTokensKey const tempPayAmount = root.swapInputParamsForm.fromTokenAmount root.swapInputParamsForm.fromTokensKey = root.swapInputParamsForm.toTokenKey root.swapInputParamsForm.fromTokenAmount = !!root.swapAdaptor.swapOutputData.toTokenAmount ? root.swapAdaptor.swapOutputData.toTokenAmount : root.swapInputParamsForm.toTokenAmount root.swapInputParamsForm.toTokenKey = tempPayToken root.swapInputParamsForm.toTokenAmount = tempPayAmount payPanel.forceActiveFocus() } } } EditSlippagePanel { id: editSlippagePanel objectName: "editSlippagePanel" Layout.fillWidth: true Layout.topMargin: Style.current.padding visible: editSlippageButton.checked selectedToToken: root.swapAdaptor.toToken toTokenAmount: root.swapAdaptor.swapOutputData.toTokenAmount loading: root.swapAdaptor.swapProposalLoading onSlippageValueChanged: { root.swapInputParamsForm.selectedSlippage = slippageValue } } ErrorTag { objectName: "errorTag" visible: d.isError Layout.maximumWidth: parent.width Layout.alignment: Qt.AlignHCenter Layout.topMargin: Style.current.smallPadding text: root.swapAdaptor.errorMessage buttonText: root.swapAdaptor.isTokenBalanceInsufficient ? qsTr("Buy crypto") : qsTr("Buy ETH") buttonVisible: visible && (root.swapAdaptor.isTokenBalanceInsufficient || root.swapAdaptor.isEthBalanceInsufficient) onButtonClicked: { // value dont update correctly if not done from here d.buyFormData.selectedWalletAddress = root.swapInputParamsForm.selectedAccountAddress d.buyFormData.selectedNetworkChainId = root.swapInputParamsForm.selectedNetworkChainId d.buyFormData.selectedTokenKey =root.swapInputParamsForm.fromTokensKey Global.openBuyCryptoModalRequested(d.buyFormData) } } } } footer: StatusDialogFooter { color: Theme.palette.baseColor3 dropShadowEnabled: true leftButtons: ObjectModel { ColumnLayout { Layout.leftMargin: Style.current.padding spacing: 0 StatusBaseText { objectName: "maxSlippageText" text: qsTr("Max slippage:") color: Theme.palette.directColor5 font.weight: Font.Medium } RowLayout { StatusBaseText { objectName: "maxSlippageValue" text: editSlippagePanel.valid ? "%1%".arg(LocaleUtils.numberToLocaleString(root.swapInputParamsForm.selectedSlippage)) : qsTr("N/A") color: Theme.palette.directColor4 font.weight: Font.Medium } StatusFlatButton { id: editSlippageButton objectName: "editSlippageButton" checkable: true checked: false icon.name: "edit_pencil" textColor: editSlippageButton.hovered ? Theme.palette.directColor1 : Theme.palette.directColor5 size: StatusBaseButton.Size.Tiny hoverColor: Theme.palette.transparent visible: !checked } } } } rightButtons: ObjectModel { RowLayout { Layout.rightMargin: Style.current.padding spacing: Style.current.bigPadding ColumnLayout { StatusBaseText { objectName: "maxFeesText" text: qsTr("Max fees:") color: Theme.palette.directColor5 font.weight: Font.Medium } StatusTextWithLoadingState { objectName: "maxFeesValue" text: { if(root.swapAdaptor.swapProposalLoading) { return Constants.dummyText } if(root.swapAdaptor.validSwapProposalReceived) { return root.swapAdaptor.currencyStore.formatCurrencyAmount( root.swapAdaptor.swapOutputData.totalFees, root.swapAdaptor.currencyStore.currentCurrency) } return "--" } customColor: Theme.palette.directColor4 font.weight: Font.Medium loading: root.swapAdaptor.swapProposalLoading } } StatusButton { objectName: "signButton" readonly property string fromTokenSymbol: !!root.swapAdaptor.fromToken ? root.swapAdaptor.fromToken.symbol ?? "" : "" loadingWithText: root.swapAdaptor.approvalPending icon.name: root.swapAdaptor.selectedAccount.migratedToKeycard ? Constants.authenticationIconByType[Constants.LoginType.Keycard] : Constants.authenticationIconByType[root.loginType] text: { if(root.swapAdaptor.validSwapProposalReceived) { if (root.swapAdaptor.approvalPending) { return qsTr("Approving %1").arg(fromTokenSymbol) } else if(root.swapAdaptor.swapOutputData.approvalNeeded) { return qsTr("Approve %1").arg(fromTokenSymbol) } } return qsTr("Swap") } tooltip.text: root.swapAdaptor.validSwapProposalReceived && root.swapAdaptor.swapOutputData.approvalNeeded ? qsTr("Approve %1 spending cap to Swap").arg(fromTokenSymbol) : "" disabledColor: Theme.palette.directColor8 interactive: root.swapAdaptor.validSwapProposalReceived && editSlippagePanel.valid && !d.isError && !root.swapAdaptor.approvalPending onClicked: { if (root.swapAdaptor.validSwapProposalReceived) { d.addMetricsEvent("next button pressed") if (root.swapAdaptor.swapOutputData.approvalNeeded) Global.openPopup(swapApproveModalComponent) else Global.openPopup(swapSignModalComponent) } } } } } } Component { id: swapApproveModalComponent SwapApproveCapModal { destroyOnClose: true formatBigNumber: (number, symbol, noSymbolOption) => root.swapAdaptor.currencyStore.formatBigNumber(number, symbol, noSymbolOption) loginType: root.swapAdaptor.selectedAccount.migratedToKeycard ? Constants.LoginType.Keycard : root.loginType feesLoading: root.swapAdaptor.swapProposalLoading fromTokenSymbol: root.swapAdaptor.fromToken.symbol fromTokenAmount: SQUtils.AmountsArithmetic.div( SQUtils.AmountsArithmetic.fromString(root.swapAdaptor.swapOutputData.approvalAmountRequired), SQUtils.AmountsArithmetic.fromNumber(1, root.swapAdaptor.fromToken.decimals ?? 18)).toFixed() fromTokenContractAddress: SQUtils.ModelUtils.getByKey(root.swapAdaptor.fromToken.addressPerChain, "chainId", root.swapInputParamsForm.selectedNetworkChainId, "address") accountName: root.swapAdaptor.selectedAccount.name accountAddress: root.swapAdaptor.selectedAccount.address accountEmoji: root.swapAdaptor.selectedAccount.emoji accountColor: Utils.getColorForId(root.swapAdaptor.selectedAccount.colorId) accountBalanceFormatted: root.swapAdaptor.selectedAccount.accountBalance.formattedBalance networkShortName: networkFilter.singleSelectionItemData.shortName networkName: networkFilter.singleSelectionItemData.chainName networkIconPath: Style.svg(networkFilter.singleSelectionItemData.iconUrl) networkBlockExplorerUrl: networkFilter.singleSelectionItemData.blockExplorerURL fiatFees: { const feesInFloat = root.swapAdaptor.currencyStore.getFiatValue(root.swapAdaptor.swapOutputData.approvalGasFees, Constants.ethToken) return root.swapAdaptor.currencyStore.formatCurrencyAmount(feesInFloat, root.swapAdaptor.currencyStore.currentCurrency) } cryptoFees: root.swapAdaptor.currencyStore.formatCurrencyAmount(parseFloat(root.swapAdaptor.swapOutputData.approvalGasFees), Constants.ethToken) estimatedTime: root.swapAdaptor.swapOutputData.estimatedTime serviceProviderName: root.swapAdaptor.swapOutputData.txProviderName serviceProviderURL: Constants.swap.paraswapUrl // TODO https://github.com/status-im/status-desktop/issues/15329 serviceProviderTandCUrl: Constants.swap.paraswapTermsAndConditionUrl // TODO https://github.com/status-im/status-desktop/issues/15329 serviceProviderIcon: Style.png("swap/%1".arg(Constants.swap.paraswapIcon)) // FIXME svg serviceProviderContractAddress: root.swapAdaptor.swapOutputData.approvalContractAddress serviceProviderHostname: Constants.swap.paraswapHostname onRejected: d.addMetricsEvent("rejected approve") onAccepted: { d.addMetricsEvent("send approve tx") root.swapAdaptor.sendApproveTx() } } } Component { id: swapSignModalComponent SwapSignModal { destroyOnClose: true formatBigNumber: (number, symbol, noSymbolOption) => root.swapAdaptor.currencyStore.formatBigNumber(number, symbol, noSymbolOption) loginType: root.swapAdaptor.selectedAccount.migratedToKeycard ? Constants.LoginType.Keycard : root.loginType feesLoading: root.swapAdaptor.swapProposalLoading fromTokenSymbol: root.swapAdaptor.fromToken.symbol fromTokenAmount: root.swapInputParamsForm.fromTokenAmount fromTokenContractAddress: SQUtils.ModelUtils.getByKey(root.swapAdaptor.fromToken.addressPerChain, "chainId", root.swapInputParamsForm.selectedNetworkChainId, "address") toTokenSymbol: root.swapAdaptor.toToken.symbol toTokenAmount: root.swapAdaptor.swapOutputData.toTokenAmount toTokenContractAddress: SQUtils.ModelUtils.getByKey(root.swapAdaptor.toToken.addressPerChain, "chainId", root.swapInputParamsForm.selectedNetworkChainId, "address") accountName: root.swapAdaptor.selectedAccount.name accountAddress: root.swapAdaptor.selectedAccount.address accountEmoji: root.swapAdaptor.selectedAccount.emoji accountColor: Utils.getColorForId(root.swapAdaptor.selectedAccount.colorId) networkShortName: networkFilter.singleSelectionItemData.shortName networkName: networkFilter.singleSelectionItemData.chainName networkIconPath: Style.svg(networkFilter.singleSelectionItemData.iconUrl) networkBlockExplorerUrl: networkFilter.singleSelectionItemData.blockExplorerURL fiatFees: root.swapAdaptor.currencyStore.formatCurrencyAmount(root.swapAdaptor.swapOutputData.totalFees, root.swapAdaptor.currencyStore.currentCurrency) cryptoFees: { const cryptoValue = root.swapAdaptor.currencyStore.getCryptoValue(root.swapAdaptor.swapOutputData.totalFees, Constants.ethToken) return root.swapAdaptor.currencyStore.formatCurrencyAmount(cryptoValue, Constants.ethToken) } slippage: root.swapInputParamsForm.selectedSlippage serviceProviderName: root.swapAdaptor.swapOutputData.txProviderName serviceProviderURL: Constants.swap.paraswapUrl // TODO https://github.com/status-im/status-desktop/issues/15329 serviceProviderTandCUrl: Constants.swap.paraswapTermsAndConditionUrl // TODO https://github.com/status-im/status-desktop/issues/15329 onRejected: d.addMetricsEvent("rejected sign") onAccepted: { d.addMetricsEvent("send swap tx") root.swapAdaptor.sendSwapTx() root.close() } } } }