feat(dapps) check max fees against balance

Also differentiate between l1 and l2 fees and check them against the
specific chain balance

Also

- Included tests for the new functionality
- Fixed some inconsistencies in handling types

Updates: #15552
This commit is contained in:
Stefan 2024-07-17 18:49:30 +03:00 committed by Stefan Dunca
parent 8db0ac94f0
commit 36e3a7cbb5
5 changed files with 242 additions and 29 deletions

View File

@ -26,6 +26,7 @@ import SortFilterProxyModel 0.2
import AppLayouts.Wallet.panels 1.0 import AppLayouts.Wallet.panels 1.0
import AppLayouts.Profile.stores 1.0 import AppLayouts.Profile.stores 1.0
import AppLayouts.Wallet.stores 1.0 as WalletStore
import mainui 1.0 import mainui 1.0
import shared.stores 1.0 import shared.stores 1.0
@ -378,6 +379,19 @@ Item {
return Constants.TransactionEstimatedTime.LessThanThreeMins return Constants.TransactionEstimatedTime.LessThanThreeMins
} }
function getSuggestedFees() {
return {
gasPrice: 2.0,
baseFee: 5.0,
maxPriorityFeePerGas: 2.0,
maxFeePerGasL: 1.0,
maxFeePerGasM: 1.1,
maxFeePerGasH: 1.2,
l1GasFee: 4.0,
eip1559Enabled: true
}
}
function hexToDec(hex) { function hexToDec(hex) {
if (hex.length > "0xfffffffffffff".length) { if (hex.length > "0xfffffffffffff".length) {
console.warn(`Beware of possible loss of precision converting ${hex}`) console.warn(`Beware of possible loss of precision converting ${hex}`)
@ -398,6 +412,12 @@ Item {
return "eth:oeth:arb" return "eth:oeth:arb"
} }
readonly property CurrenciesStore currencyStore: CurrenciesStore {} readonly property CurrenciesStore currencyStore: CurrenciesStore {}
readonly property WalletStore.WalletAssetsStore walletAssetsStore: WalletStore.WalletAssetsStore {
// Silence warnings
assetsWithFilteredBalances: ListModel {}
// Name mismatch between storybook and production
readonly property var groupedAccountAssetsModel: groupedAccountsAssetsModel
}
} }
onDisplayToastMessage: (message, isErr) => { onDisplayToastMessage: (message, isErr) => {

View File

@ -155,7 +155,7 @@ function formatApproveSessionResponse(networksArray, accountsArray, custom) {
} }
function formatSessionRequest(chainId, method, params, topic) { function formatSessionRequest(chainId, method, params, topic) {
let paramsStr = params.map(param => `"${param}"`).join(',') let paramsStr = params.map(param => `${param}`).join(',')
return `{ return `{
"id": 1717149885151715, "id": 1717149885151715,
"params": { "params": {

View File

@ -14,6 +14,7 @@ import AppLayouts.Wallet.services.dapps 1.0
import AppLayouts.Wallet.services.dapps.types 1.0 import AppLayouts.Wallet.services.dapps.types 1.0
import AppLayouts.Profile.stores 1.0 import AppLayouts.Profile.stores 1.0
import AppLayouts.Wallet.panels 1.0 import AppLayouts.Wallet.panels 1.0
import AppLayouts.Wallet.stores 1.0 as WalletStore
import shared.stores 1.0 import shared.stores 1.0
@ -82,7 +83,6 @@ Item {
Component { Component {
id: dappsStoreComponent id: dappsStoreComponent
DAppsStore { DAppsStore {
property string dappsListReceivedJsonStr: '[]' property string dappsListReceivedJsonStr: '[]'
@ -119,6 +119,24 @@ Item {
function updateWalletConnectSessions(activeTopicsJson) { function updateWalletConnectSessions(activeTopicsJson) {
updateWalletConnectSessionsCalls.push({activeTopicsJson}) updateWalletConnectSessionsCalls.push({activeTopicsJson})
} }
function getEstimatedTime(chainId, maxFeePerGas) {
return Constants.TransactionEstimatedTime.LessThanThreeMins
}
property var mockedSuggestedFees: ({
gasPrice: 2.0,
baseFee: 5.0,
maxPriorityFeePerGas: 2.0,
maxFeePerGasL: 1.0,
maxFeePerGasM: 1.1,
maxFeePerGasH: 1.2,
l1GasFee: 0.0,
eip1559Enabled: true
})
function getSuggestedFees() {
return mockedSuggestedFees
}
} }
} }
@ -127,11 +145,25 @@ Item {
QtObject { QtObject {
readonly property ListModel filteredFlatModel: ListModel { readonly property ListModel filteredFlatModel: ListModel {
ListElement { chainId: 1 } ListElement {
chainId: 1
layer: 1
}
ListElement { ListElement {
chainId: 2 chainId: 2
chainName: "Test Chain" chainName: "Test Chain"
iconUrl: "network/Network=Ethereum" iconUrl: "network/Network=Ethereum"
layer: 2
}
// Used by tst_balanceCheck
ListElement {
chainId: 11155111
layer: 1
}
// Used by tst_balanceCheck
ListElement {
chainId: 421613
layer: 2
} }
} }
@ -144,24 +176,45 @@ Item {
color: "#2A4AF5" color: "#2A4AF5"
} }
ListElement { address: "0x3a" } ListElement { address: "0x3a" }
// Account from GroupedAccountsAssetsModel
ListElement { address: "0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240" }
} }
function getNetworkShortNames(chainIds) { function getNetworkShortNames(chainIds) {
return "eth:oeth:arb" return "eth:oeth:arb"
} }
readonly property var currencyStore: CurrenciesStore {}
readonly property var walletAssetsStore: assetsStoreMock
} }
} }
WalletStore.WalletAssetsStore {
id: assetsStoreMock
// Silence warnings
assetsWithFilteredBalances: ListModel {}
readonly property var groupedAccountAssetsModel: groupedAccountsAssetsModel
}
Component { Component {
id: dappsRequestHandlerComponent id: dappsRequestHandlerComponent
DAppsRequestHandler { DAppsRequestHandler {
currenciesStore: CurrenciesStore {} currenciesStore: CurrenciesStore {}
assetsStore: assetsStoreMock
property var maxFeesUpdatedCalls: []
onMaxFeesUpdated: function(fiatMaxFees, ethMaxFees, haveEnoughFunds, haveEnoughForFees, symbol, feesInfo) {
maxFeesUpdatedCalls.push({fiatMaxFees, ethMaxFees, haveEnoughFunds, haveEnoughForFees, symbol, feesInfo})
}
} }
} }
TestCase { TestCase {
id: requestHandlerTest id: requestHandlerTest
name: "DAppsRequestHandler" name: "DAppsRequestHandler"
// Ensure mocked GroupedAccountsAssetsModel is properly initialized
when: windowShown
property DAppsRequestHandler handler: null property DAppsRequestHandler handler: null
@ -209,7 +262,7 @@ Item {
let chainId = 2 let chainId = 2
let method = "personal_sign" let method = "personal_sign"
let message = "hello world" let message = "hello world"
let params = [Helpers.strToHex(message), testAddressUpper] let params = [`"${Helpers.strToHex(message)}"`, `"${testAddressUpper}"`]
let topic = "b536a" let topic = "b536a"
let session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic)) let session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic))
// Expect to have calls to getActiveSessions from service initialization // Expect to have calls to getActiveSessions from service initialization
@ -218,6 +271,89 @@ Item {
compare(sdk.getActiveSessionsCallbacks.length, 1, "expected DAppsRequestHandler call sdk.getActiveSessions") compare(sdk.getActiveSessionsCallbacks.length, 1, "expected DAppsRequestHandler call sdk.getActiveSessions")
} }
function test_balanceCheck_data() {
return [{
tag: "have_enough_funds",
chainId: 11155111,
expect: {
haveEnoughForFees: true,
}
},
{
tag: "doest_have_enough_funds",
chainId: 11155111,
// Override the suggestedFees to a higher value
maxFeePerGasM: 1000000.0, /*GWEI*/
expect: {
haveEnoughForFees: false,
}
},
{
tag: "check_l2_doesnt_have_enough_funds_on_l1",
chainId: 421613,
// Override the l1 additional fees
l1GasFee: 1000000000.0,
expect: {
haveEnoughForFees: false,
}
},
{
tag: "check_l2_doesnt_have_enough_funds_on_l2",
chainId: 421613,
// Override the l2 to a higher value
maxFeePerGasM: 1000000.0, /*GWEI*/
// Override the l1 additional fees
l1GasFee: 10.0,
expect: {
haveEnoughForFees: false,
}
}]
}
function test_balanceCheck(data) {
let sdk = handler.sdk
// Override the suggestedFees
if (!!data.maxFeePerGasM) {
handler.store.mockedSuggestedFees.maxFeePerGasM = data.maxFeePerGasM
}
if (!!data.l1GasFee) {
handler.store.mockedSuggestedFees.l1GasFee = data.l1GasFee
}
let testAddress = "0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240"
let chainId = data.chainId
let method = "eth_sendTransaction"
let message = "hello world"
let params = [`{
"data": "0x",
"from": "${testAddress}",
"to": "0x2",
"value": "0x12345"
}`]
let topic = "b536a"
let session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic))
sdk.sessionRequestEvent(session)
compare(sdk.getActiveSessionsCallbacks.length, 1, "expected DAppsRequestHandler call sdk.getActiveSessions")
let callback = sdk.getActiveSessionsCallbacks[0].callback
callback({"b536a": JSON.parse(Testing.formatApproveSessionResponse([chainId, 7], [testAddress]))})
compare(handler.maxFeesUpdatedCalls.length, 1, "expected a call to handler.onMaxFeesUpdated")
let args = handler.maxFeesUpdatedCalls[0]
verify(args.ethMaxFees > 0, "expected ethMaxFees to be set")
// storybook's CurrenciesStore mock up getFiatValue returns the balance
compare(args.fiatMaxFees.toString(), args.ethMaxFees.toString(), "expected fiatMaxFees to be set")
verify(args.haveEnoughFunds, "expected haveEnoughFunds to be set")
compare(args.haveEnoughForFees, data.expect.haveEnoughForFees, "expected haveEnoughForFees to be set")
compare(args.symbol, "$", "expected symbol to be set")
verify(!!args.feesInfo, "expected feesInfo to be set")
}
} }
TestCase { TestCase {
@ -238,8 +374,7 @@ Item {
} }
} }
SignalSpy { readonly property SignalSpy sessionRequestSpy: SignalSpy {
id: sessionRequestSpy
target: walletConnectServiceTest.service target: walletConnectServiceTest.service
signalName: "sessionRequest" signalName: "sessionRequest"
@ -337,7 +472,7 @@ Item {
let chainId = 2 let chainId = 2
let method = "personal_sign" let method = "personal_sign"
let message = "hello world" let message = "hello world"
let params = [Helpers.strToHex(message), testAddress] let params = [`"${Helpers.strToHex(message)}"`, `"${testAddress}"`]
let topic = "b536a" let topic = "b536a"
let session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic)) let session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic))
// Expect to have calls to getActiveSessions from service initialization // Expect to have calls to getActiveSessions from service initialization
@ -674,7 +809,7 @@ Item {
let network = networksMdodel.get(1) let network = networksMdodel.get(1)
let method = "personal_sign" let method = "personal_sign"
let message = "hello world" let message = "hello world"
let params = [Helpers.strToHex(message), account.address] let params = [`"${Helpers.strToHex(message)}"`, `"${account.address}"`]
let topic = "b536a" let topic = "b536a"
let requestEvent = JSON.parse(Testing.formatSessionRequest(network.chainId, method, params, topic)) let requestEvent = JSON.parse(Testing.formatSessionRequest(network.chainId, method, params, topic))
let request = tc.createTemporaryObject(sessionRequestComponent, root, { let request = tc.createTemporaryObject(sessionRequestComponent, root, {

View File

@ -2,6 +2,7 @@ import QtQuick 2.15
import AppLayouts.Wallet.services.dapps 1.0 import AppLayouts.Wallet.services.dapps 1.0
import AppLayouts.Wallet.services.dapps.types 1.0 import AppLayouts.Wallet.services.dapps.types 1.0
import AppLayouts.Wallet.stores 1.0 as WalletStore
import StatusQ.Core.Utils 0.1 as SQUtils import StatusQ.Core.Utils 0.1 as SQUtils
@ -18,6 +19,7 @@ SQUtils.QObject {
required property var accountsModel required property var accountsModel
required property var networksModel required property var networksModel
required property CurrenciesStore currenciesStore required property CurrenciesStore currenciesStore
required property WalletStore.WalletAssetsStore assetsStore
property alias requestsModel: requests property alias requestsModel: requests
@ -124,6 +126,7 @@ SQUtils.QObject {
console.error("Error finding network for event", JSON.stringify(event)) console.error("Error finding network for event", JSON.stringify(event))
return null return null
} }
let data = extractMethodData(event, method) let data = extractMethodData(event, method)
if(!data) { if(!data) {
console.error("Error in event data lookup", JSON.stringify(event)) console.error("Error in event data lookup", JSON.stringify(event))
@ -168,9 +171,19 @@ SQUtils.QObject {
let estimatedTimeEnum = getEstimatedTimeInterval(data, method, obj.network.chainId) let estimatedTimeEnum = getEstimatedTimeInterval(data, method, obj.network.chainId)
root.estimatedTimeUpdated(estimatedTimeEnum) root.estimatedTimeUpdated(estimatedTimeEnum)
let st = getEstimatedFeesStatus(data, method, obj.network.chainId) const mainNet = lookupMainnetNetwork()
let mainChainId = obj.network.chainId
if (!!mainNet) {
mainChainId = mainNet.chainId
} else {
console.error("Error finding mainnet network")
}
let st = getEstimatedFeesStatus(data, method, obj.network.chainId, mainChainId)
root.maxFeesUpdated(st.fiatMaxFees, st.maxFeesEth, st.haveEnoughFunds, st.haveEnoughFees, st.symbol, st.feesInfo) let fundsStatus = checkFundsStatus(st.feesInfo.maxFees, st.feesInfo.l1GasFee, account.address, obj.network.chainId, mainNet.chainId)
root.maxFeesUpdated(st.fiatMaxFees.toNumber(), st.maxFeesEth, fundsStatus.haveEnoughFunds,
fundsStatus.haveEnoughForFees, st.symbol, st.feesInfo)
}) })
return obj return obj
@ -216,6 +229,11 @@ SQUtils.QObject {
return SQUtils.ModelUtils.getByKey(root.networksModel, "chainId", chainId) return SQUtils.ModelUtils.getByKey(root.networksModel, "chainId", chainId)
} }
/// Returns null if the network is not found
function lookupMainnetNetwork() {
return SQUtils.ModelUtils.getByKey(root.networksModel, "layer", 1)
}
function extractMethodData(event, method) { function extractMethodData(event, method) {
if (method === SessionRequest.methods.personalSign.name || if (method === SessionRequest.methods.personalSign.name ||
method === SessionRequest.methods.sign.name) method === SessionRequest.methods.sign.name)
@ -362,7 +380,7 @@ SQUtils.QObject {
// maxPriorityFeePerGas // maxPriorityFeePerGas
// gasPrice // gasPrice
// } // }
function getEstimatedMaxFees(data, method, chainId) { function getEstimatedMaxFees(data, method, chainId, mainNetChainId) {
let tx = {} let tx = {}
if (d.isTransactionMethod(method)) { if (d.isTransactionMethod(method)) {
tx = d.getTxObject(method, data) tx = d.getTxObject(method, data)
@ -371,6 +389,8 @@ SQUtils.QObject {
let Math = SQUtils.AmountsArithmetic let Math = SQUtils.AmountsArithmetic
let gasLimit = Math.fromString("21000") let gasLimit = Math.fromString("21000")
let gasPrice, maxFeePerGas, maxPriorityFeePerGas let gasPrice, maxFeePerGas, maxPriorityFeePerGas
let l1GasFee = Math.fromNumber(0)
// Beware, the tx values are standard blockchain hex big number values; the fees values are nim's float64 values, hence the complex conversions // Beware, the tx values are standard blockchain hex big number values; the fees values are nim's float64 values, hence the complex conversions
if (!!tx.maxFeePerGas && !!tx.maxPriorityFeePerGas) { if (!!tx.maxFeePerGas && !!tx.maxPriorityFeePerGas) {
let maxFeePerGasDec = root.store.hexToDec(tx.maxFeePerGas) let maxFeePerGasDec = root.store.hexToDec(tx.maxFeePerGas)
@ -384,7 +404,7 @@ SQUtils.QObject {
maxPriorityFeePerGas = fees.maxPriorityFeePerGas maxPriorityFeePerGas = fees.maxPriorityFeePerGas
if (fees.eip1559Enabled) { if (fees.eip1559Enabled) {
if (!!fees.maxFeePerGasM) { if (!!fees.maxFeePerGasM) {
gasPrice = Math.fromString(fees.maxFeePerGasM) gasPrice = Math.fromNumber(fees.maxFeePerGasM)
maxFeePerGas = fees.maxFeePerGasM maxFeePerGas = fees.maxFeePerGasM
} else if(!!tx.maxFeePerGas) { } else if(!!tx.maxFeePerGas) {
let maxFeePerGasDec = root.store.hexToDec(tx.maxFeePerGas) let maxFeePerGasDec = root.store.hexToDec(tx.maxFeePerGas)
@ -396,38 +416,75 @@ SQUtils.QObject {
} }
} else { } else {
if (!!fees.gasPrice) { if (!!fees.gasPrice) {
gasPrice = Math.fromString(fees.gasPrice) gasPrice = Math.fromNumber(fees.gasPrice)
} else { } else {
console.error("Error fetching suggested fees") console.error("Error fetching suggested fees")
return return
} }
} }
gasPrice = Math.sum(gasPrice, Math.fromString(fees.l1GasFee)) l1GasFee = Math.fromNumber(fees.l1GasFee)
} }
let maxFees = Math.times(gasLimit, gasPrice) let maxFees = Math.times(gasLimit, gasPrice)
return {maxFees, maxFeePerGas, maxPriorityFeePerGas, gasPrice} return {maxFees, maxFeePerGas, maxPriorityFeePerGas, gasPrice, l1GasFee}
} }
function getEstimatedFeesStatus(data, method, chainId) { // Returned values are Big numbers
function getEstimatedFeesStatus(data, method, chainId, mainNetChainId) {
let Math = SQUtils.AmountsArithmetic let Math = SQUtils.AmountsArithmetic
let feesInfo = getEstimatedMaxFees(data, method, chainId) let feesInfo = getEstimatedMaxFees(data, method, chainId, mainNetChainId)
let maxFeesEth = Math.div(feesInfo.maxFees, Math.fromString("1000000000")) let totalMaxFees = Math.sum(feesInfo.maxFees, feesInfo.l1GasFee)
let maxFeesEth = Math.div(totalMaxFees, Math.fromString("1000000000"))
// TODO #15192: extract account.balance
//let accountFundsEth = account.balance
//let haveEnoughFees = Math.cmp(accountFundsEth, maxFeesEth) >= 0
let haveEnoughFees = true
let maxFeesEthStr = maxFeesEth.toString() let maxFeesEthStr = maxFeesEth.toString()
let fiatMaxFees = root.currenciesStore.getFiatValue(maxFeesEthStr, Constants.ethToken) let fiatMaxFeesStr = root.currenciesStore.getFiatValue(maxFeesEthStr, Constants.ethToken)
let symbol = root.currenciesStore.currentCurrency let fiatMaxFees = Math.fromString(fiatMaxFeesStr)
let symbol = root.currenciesStore.currentCurrencySymbol
// We don't process the transaction so we don't have this information yet return {fiatMaxFees, maxFeesEth, symbol, feesInfo}
}
function checkBalanceForChain(balances, address, chainId, fees) {
let Math = SQUtils.AmountsArithmetic
let accEth = SQUtils.ModelUtils.getFirstModelEntryIf(balances, (balance) => {
return balance.account.toLowerCase() === address.toLowerCase() && balance.chainId === chainId
})
if (!accEth) {
console.error("Error balance lookup for account ", address, " on chain ", chainId)
return {haveEnoughForFees, haveEnoughFunds}
}
let accountFundsWei = Math.fromString(accEth.balance)
let accountFundsEth = Math.div(accountFundsWei, Math.fromString("1000000000000000000"))
let feesEth = Math.div(fees, Math.fromString("1000000000"))
return Math.cmp(accountFundsEth, feesEth) >= 0
}
function checkFundsStatus(maxFees, l1GasFee, address, chainId, mainNetChainId) {
let Math = SQUtils.AmountsArithmetic
let haveEnoughForFees = false
// TODO #15192: extract funds from transaction and check against it
let haveEnoughFunds = true let haveEnoughFunds = true
return {fiatMaxFees, maxFeesEth, haveEnoughFunds, haveEnoughFees, symbol, feesInfo}
let token = SQUtils.ModelUtils.getByKey(root.assetsStore.groupedAccountAssetsModel, "tokensKey", Constants.ethToken)
if (!token || !token.balances) {
console.error("Error token balances lookup for ETH")
return {haveEnoughForFees, haveEnoughFunds}
}
if (chainId == mainNetChainId) {
const finalFees = Math.sum(maxFees, l1GasFee)
haveEnoughForFees = checkBalanceForChain(token.balances, address, chainId, finalFees)
} else {
const haveEnoughOnChain = checkBalanceForChain(token.balances, address, chainId, maxFees)
const haveEnoughOnMain = checkBalanceForChain(token.balances, address, mainNetChainId, l1GasFee)
haveEnoughForFees = haveEnoughOnChain && haveEnoughOnMain
}
return {haveEnoughForFees, haveEnoughFunds}
} }
function isTransactionMethod(method) { function isTransactionMethod(method) {

View File

@ -239,6 +239,7 @@ QObject {
accountsModel: root.validAccounts accountsModel: root.validAccounts
networksModel: root.flatNetworks networksModel: root.flatNetworks
currenciesStore: root.walletRootStore.currencyStore currenciesStore: root.walletRootStore.currencyStore
assetsStore: root.walletRootStore.walletAssetsStore
onSessionRequest: (request) => { onSessionRequest: (request) => {
timeoutTimer.stop() timeoutTimer.stop()