feat(@desktop/wallet): Getting swap proposal

fixes #14828
This commit is contained in:
Khushboo Mehta 2024-06-06 16:05:31 +02:00 committed by Khushboo-dev-cpp
parent a12a6a4894
commit 8d6d6bdd84
16 changed files with 765 additions and 287 deletions

View File

@ -53,6 +53,7 @@ SplitView {
}
currencyStore: CurrenciesStore {}
swapFormData: d.swapInputParamsForm
swapOutputData: SwapOutputData {}
}
}

View File

@ -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)
}
}
}
}

View File

@ -38,6 +38,7 @@ Item {
}
currencyStore: CurrenciesStore {}
swapFormData: SwapInputParamsForm {}
swapOutputData: SwapOutputData {}
}
}

View File

@ -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))
}
}
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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()
}
}
}
}
}

View File

@ -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<balances.count; i++) {
let balancePerAddressPerChain = ModelUtils.get(balances, i)
if (chainIds.includes(-1) || chainIds.includes(balancePerAddressPerChain.chainId))
totalBalance += AmountsArithmetic.toNumber(balancePerAddressPerChain.balance, decimals)
}
return totalBalance
}
}
Connections {
target: root.swapStore
function onSuggestedRoutesReady(txRoutes) {
root.swapOutputData.reset()
root.validSwapProposalReceived = false
root.swapProposalLoading = false
root.swapOutputData.rawPaths = txRoutes.rawPaths
// if valid route was found
if(txRoutes.suggestedRoutes.count === 1) {
root.validSwapProposalReceived = true
root.swapOutputData.bestRoutes = txRoutes.suggestedRoutes
root.swapOutputData.toTokenAmount = root.swapStore.getWei2Eth(txRoutes.amountToReceive, root.toToken.decimals).toString()
let gasTimeEstimate = txRoutes.gasTimeEstimate
let totalTokenFeesInFiat = 0
if (!!root.fromToken && !!root.fromToken .marketDetails && !!root.fromToken.marketDetails.currencyPrice)
totalTokenFeesInFiat = gasTimeEstimate.totalTokenFees * root.fromToken.marketDetails.currencyPrice.amount
root.swapOutputData.totalFees = root.currencyStore.getFiatValue(gasTimeEstimate.totalFeesInEth, Constants.ethToken) + totalTokenFeesInFiat
root.swapOutputData.approvalNeeded = ModelUtils.get(root.swapOutputData.bestRoutes, 0, "route").approvalRequired
}
else {
root.swapOutputData.hasError = true
}
}
}
function reset() {
root.swapFormData.resetFormData()
root.swapOutputData.reset()
root.validSwapProposalReceived = false
root.swapProposalLoading = false
}
function getNetworkShortNames(chainIds) {
var networkString = ""
let chainIdsArray = chainIds.split(":")
@ -119,140 +246,33 @@ QObject {
return null
}
// 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: __assetsWithFilteredBalances
proxyRoles: [
FastExpressionRole {
name: "currentBalance"
expression: {
// FIXME recalc when selectedNetworkChainId changes
root.swapFormData.selectedNetworkChainId
return __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
}
// Internal properties and functions -----------------------------------------------------------------------------------------------------------------------------
readonly property var __fromToken: ModelUtils.getByKey(root.walletAssetsStore.walletTokensStore.plainTokensBySymbolModel, "key", root.swapFormData.fromTokensKey)
// Internal model filtering balances by the account selected in the AccountsModalHeader
SubmodelProxyModel {
id: __assetsWithFilteredBalances
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
}*/
]
}
}
SubmodelProxyModel {
id: filteredBalancesModel
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<balances.count; i++) {
let balancePerAddressPerChain = ModelUtils.get(balances, i)
if (chainIds.includes(-1) || chainIds.includes(balancePerAddressPerChain.chainId))
totalBalance += AmountsArithmetic.toNumber(balancePerAddressPerChain.balance, decimals)
}
return totalBalance
}
function fetchSuggestedRoutes() {
root.swapProposalReady = false
root.swapProposalLoading = true
let amount = !!root.swapFormData.fromTokenAmount ? AmountsArithmetic.fromString(root.swapFormData.fromTokenAmount): NaN
root.swapOutputData.reset()
// Identify new swap with a different uuid
d.uuid = Utils.uuid()
if(!isNaN(amount) && !!root.fromToken && root.swapFormData.isFormFilledCorrectly()) {
let fromTokenAmountInWei = AmountsArithmetic.fromNumber(amount, !!root.fromToken ? root.fromToken.decimals: 18).toString()
let account = getSelectedAccount(root.swapFormData.selectedAccountIndex)
let accountAddress = account.address
let disabledChainIds = getDisabledChainIds(root.swapFormData.selectedNetworkChainId)
let preferedChainIds = getAllChainIds()
root.validSwapProposalReceived = false
// TODO #14825: amount should be in BigInt string representation (fromTokenAmount * 10^decimals)
// Make sure that's replaced when the input component is integrated
root.swapStore.fetchSuggestedRoutes(accountAddress, accountAddress,
root.swapFormData.fromTokenAmount, root.swapFormData.fromTokensKey, root.swapFormData.toTokenKey,
disabledChainIds, disabledChainIds, preferedChainIds,
Constants.SendType.Swap, "")
// Identify new swap with a different uuid
d.uuid = Utils.uuid()
let account = getSelectedAccount(root.swapFormData.selectedAccountIndex)
let accountAddress = account.address
let disabledChainIds = getDisabledChainIds(root.swapFormData.selectedNetworkChainId)
let preferedChainIds = getAllChainIds()
// TODO #14825: amount should be in BigInt string representation (fromTokenAmount * 10^decimals)
// Make sure that's replaced when the input component is integrated
root.swapStore.fetchSuggestedRoutes(accountAddress, accountAddress,
fromTokenAmountInWei, root.swapFormData.fromTokensKey, root.swapFormData.toTokenKey,
disabledChainIds, disabledChainIds, preferedChainIds,
Constants.SendType.Swap, "")
} else {
root.validSwapProposalReceived = false
root.swapProposalLoading = false
}
}
function sendApproveTx() {
@ -261,7 +281,7 @@ QObject {
root.swapStore.authenticateAndTransfer(d.uuid, accountAddress, accountAddress,
root.swapFormData.fromTokensKey, root.swapFormData.toTokenKey,
Constants.SendType.Approve, "", false, d.rawPaths)
Constants.SendType.Approve, "", false, root.swapOutputData.rawPaths)
}
function sendSwapTx() {
@ -270,6 +290,6 @@ QObject {
root.swapStore.authenticateAndTransfer(d.uuid, accountAddress, accountAddress,
root.swapFormData.fromTokensKey, root.swapFormData.toTokenKey,
Constants.SendType.Swap, "", false, d.rawPaths)
Constants.SendType.Swap, "", false, root.swapOutputData.rawPaths)
}
}

View File

@ -0,0 +1,26 @@
import QtQuick 2.15
/* This is so that all the data from the response
to the swap request can be placed here at one place. */
QtObject {
id: root
property string fromTokenAmount: "0"
property string toTokenAmount: "0"
property real totalFees: 0
property var bestRoutes: []
property bool approvalNeeded
property bool hasError
property var rawPaths: []
function reset() {
root.fromTokenAmount = "0"
root.toTokenAmount = "0"
root.totalFees = 0
root.bestRoutes = []
root.approvalNeeded = false
root.hasError = false
root.rawPaths = []
}
}

View File

@ -1,3 +1,4 @@
SwapModal 1.0 SwapModal.qml
SwapInputParamsForm 1.0 SwapInputParamsForm.qml
SwapModalAdaptor 1.0 SwapModalAdaptor.qml
SwapOutputData 1.0 SwapOutputData.qml

View File

@ -37,4 +37,8 @@ QtObject {
root.walletSectionSendInst.authenticateAndTransferWithParameters(uuid, accountFrom, accountTo,
tokenFrom, tokenTo, sendType, tokenName, tokenIsOwnerToken, paths)
}
function getWei2Eth(wei, decimals) {
return globalUtils.wei2Eth(wei, decimals)
}
}

View File

@ -1251,6 +1251,7 @@ QtObject {
walletAssetsStore: root.walletAssetsStore
currencyStore: root.currencyStore
swapFormData: swapInputParamsForm
swapOutputData: SwapOutputData{}
}
onClosed: destroy()
}

View File

@ -1317,4 +1317,11 @@ QtObject {
InProgress = 1,
Done = 2
}
readonly property QtObject swap: QtObject {
/* We should be very careful here, this is the token key for Status network token currently,
but in case the logic for keys changes in the backend, it should be updated here as well */
readonly property string testStatusTokenKey: "STT"
readonly property string mainnetStatusTokenKey: "SNT"
}
}