mirror of
https://github.com/status-im/status-desktop.git
synced 2025-01-09 13:56:10 +00:00
106988d534
This PR is refactoring the dapps service to avoid code duplication between SDKs and also to avoid overlapping requests/responses. It brings Browser Connect inline with Wallet Connect in terms of session management and sign transactions. New architecture: WalletConnectService becomes DAppsService. Its responsibility is to provide dapp access to the app. This is the component currently used by the UI What does it do: 1. Provide dapp APIs line connect, disconnect, session requests etc 2. Spawn app notifications on dapp events 3. Timeout requests if the dapp does not respons DAppsRequestHandler becomes DAppsModule. This component is consumed by the DAppService. Its responsibility is to aggregate all the building blocks for the dapps, but does not control any of the dapp features or consume the SDKs requests. What does it do: 1. Aggregate all the building blocks for dapps (currently known as plugins) DAppConnectionsPlugin - This component provides the session management features line connect, disconnect and provide a model with the connected dapps. SignRequestPlugin - This component provides the sign request management. It receives the sign request from the dapp, translates it to what Status understands and manages the lifecycle of the request.
1190 lines
48 KiB
QML
1190 lines
48 KiB
QML
import QtQuick 2.15
|
|
|
|
import AppLayouts.Wallet.services.dapps 1.0
|
|
import AppLayouts.Wallet.services.dapps.plugins 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 shared.stores 1.0
|
|
import utils 1.0
|
|
|
|
SQUtils.QObject {
|
|
id: root
|
|
|
|
required property WalletConnectSDKBase sdk
|
|
required property DAppsStore store
|
|
required property var accountsModel
|
|
// Required roles: chainId, layer, isOnline
|
|
required property var networksModel
|
|
required property CurrenciesStore currenciesStore
|
|
required property WalletStore.WalletAssetsStore assetsStore
|
|
|
|
property alias requestsModel: requests
|
|
readonly property bool isServiceOnline: chainsSupervisorPlugin.anyChainAvailable && sdk.sdkReady
|
|
|
|
function subscribeForFeeUpdates(topic, id) {
|
|
d.subscribeForFeeUpdates(topic, id)
|
|
}
|
|
|
|
function pair(uri) {
|
|
return sdk.pair(uri)
|
|
}
|
|
|
|
/// Approves or rejects the session proposal
|
|
function approvePairSession(key, approvedChainIds, accountAddress) {
|
|
const approvedNamespaces = JSON.parse(
|
|
DAppsHelpers.buildSupportedNamespaces(approvedChainIds,
|
|
[accountAddress],
|
|
SessionRequest.getSupportedMethods())
|
|
)
|
|
|
|
if (siwePlugin.connectionApproved(key, approvedNamespaces)) {
|
|
return
|
|
}
|
|
|
|
if (!d.activeProposals.has(key)) {
|
|
console.error("No active proposal found for key: " + key)
|
|
return
|
|
}
|
|
|
|
const proposal = d.activeProposals.get(key)
|
|
d.acceptedSessionProposal = proposal
|
|
d.acceptedNamespaces = approvedNamespaces
|
|
|
|
sdk.buildApprovedNamespaces(key, proposal.params, approvedNamespaces)
|
|
}
|
|
|
|
/// Rejects the session proposal
|
|
function rejectPairSession(id) {
|
|
if (siwePlugin.connectionRejected(id)) {
|
|
return
|
|
}
|
|
sdk.rejectSession(id)
|
|
}
|
|
|
|
/// Disconnects the WC session with the given topic
|
|
function disconnectSession(sessionTopic) {
|
|
wcSDK.disconnectSession(sessionTopic)
|
|
}
|
|
|
|
function validatePairingUri(uri){
|
|
const info = DAppsHelpers.extractInfoFromPairUri(uri)
|
|
sdk.getActiveSessions((sessions) => {
|
|
// Check if the URI is already paired
|
|
let validationState = Pairing.errors.uriOk
|
|
for (const key in sessions) {
|
|
if (sessions[key].pairingTopic === info.topic) {
|
|
validationState = Pairing.errors.alreadyUsed
|
|
break
|
|
}
|
|
}
|
|
|
|
// Check if expired
|
|
if (validationState === Pairing.errors.uriOk) {
|
|
const now = (new Date().getTime())/1000
|
|
if (info.expiry < now) {
|
|
validationState = Pairing.errors.expired
|
|
}
|
|
}
|
|
|
|
root.pairingValidated(validationState)
|
|
});
|
|
}
|
|
|
|
signal sessionRequest(var id)
|
|
/*type - maps to Constants.ephemeralNotificationType*/
|
|
signal displayToastMessage(string message, int type)
|
|
signal pairingValidated(int validationState)
|
|
signal pairingResponse(int state) // Maps to Pairing.errors
|
|
signal connectDApp(var chains, string dAppUrl, string dAppName, string dAppIcon, var key)
|
|
signal approveSessionResult(var proposalId, bool error, var topic)
|
|
signal dappDisconnected(var topic, string url, bool error)
|
|
|
|
Connections {
|
|
target: sdk
|
|
|
|
function onRejectSessionResult(proposalId, err) {
|
|
if (!d.activeProposals.has(proposalId)) {
|
|
console.error("No active proposal found for key: " + proposalId)
|
|
return
|
|
}
|
|
|
|
const proposal = d.activeProposals.get(proposalId)
|
|
d.activeProposals.delete(proposalId)
|
|
|
|
const app_url = proposal.params.proposer.metadata.url ?? "-"
|
|
const app_domain = SQUtils.StringUtils.extractDomainFromLink(app_url)
|
|
if(err) {
|
|
root.pairingResponse(Pairing.errors.unknownError)
|
|
root.displayToastMessage(qsTr("Failed to reject connection request for %1").arg(app_domain), Constants.ephemeralNotificationType.danger)
|
|
} else {
|
|
root.displayToastMessage(qsTr("Connection request for %1 was rejected").arg(app_domain), Constants.ephemeralNotificationType.success)
|
|
}
|
|
}
|
|
|
|
function onApproveSessionResult(proposalId, session, err) {
|
|
if (!d.activeProposals.has(proposalId)) {
|
|
console.error("No active proposal found for key: " + proposalId)
|
|
return
|
|
}
|
|
|
|
if (!d.acceptedSessionProposal || d.acceptedSessionProposal.id !== proposalId) {
|
|
console.error("No accepted proposal found for key: " + proposalId)
|
|
d.activeProposals.delete(proposalId)
|
|
return
|
|
}
|
|
|
|
const proposal = d.activeProposals.get(proposalId)
|
|
d.activeProposals.delete(proposalId)
|
|
d.acceptedSessionProposal = null
|
|
d.acceptedNamespaces = null
|
|
|
|
if (err) {
|
|
root.pairingResponse(Pairing.errors.unknownError)
|
|
return
|
|
}
|
|
|
|
// TODO #14754: implement custom dApp notification
|
|
const app_url = proposal.params.proposer.metadata.url ?? "-"
|
|
const app_domain = SQUtils.StringUtils.extractDomainFromLink(app_url)
|
|
root.displayToastMessage(qsTr("Connected to %1 via WalletConnect").arg(app_domain), Constants.ephemeralNotificationType.success)
|
|
|
|
// Persist session
|
|
if(!root.store.addWalletConnectSession(JSON.stringify(session))) {
|
|
console.error("Failed to persist session")
|
|
}
|
|
|
|
// Notify client
|
|
root.approveSessionResult(proposalId, err, session.topic)
|
|
}
|
|
|
|
function onBuildApprovedNamespacesResult(key, approvedNamespaces, error) {
|
|
if (!d.activeProposals.has(key)) {
|
|
console.error("No active proposal found for key: " + key)
|
|
return
|
|
}
|
|
|
|
if(error || !approvedNamespaces) {
|
|
// Check that it contains Non conforming namespaces"
|
|
if (error.includes("Non conforming namespaces")) {
|
|
root.pairingResponse(Pairing.errors.unsupportedNetwork)
|
|
} else {
|
|
root.pairingResponse(Pairing.errors.unknownError)
|
|
}
|
|
return
|
|
}
|
|
|
|
approvedNamespaces = applyChainAgnosticFix(approvedNamespaces)
|
|
|
|
if (d.acceptedSessionProposal) {
|
|
sdk.approveSession(d.acceptedSessionProposal, approvedNamespaces)
|
|
} else {
|
|
const proposal = d.activeProposals.get(key)
|
|
const res = DAppsHelpers.extractChainsAndAccountsFromApprovedNamespaces(approvedNamespaces)
|
|
const chains = res.chains
|
|
const dAppUrl = proposal.params.proposer.metadata.url
|
|
const dAppName = proposal.params.proposer.metadata.name
|
|
const dAppIcons = proposal.params.proposer.metadata.icons
|
|
const dAppIcon = dAppIcons && dAppIcons.length > 0 ? dAppIcons[0] : ""
|
|
|
|
root.connectDApp(chains, dAppUrl, dAppName, dAppIcon, key)
|
|
}
|
|
}
|
|
|
|
//Special case for chain agnostic dapps
|
|
//WC considers the approved namespace as valid, but there's no chainId or account established
|
|
//Usually this request is declared by using `eip155:0`, but we don't support this chainID, resulting in empty `chains` and `accounts`
|
|
//The established connection will use for all user approved chains and accounts
|
|
//This fix is applied to all valid namespaces that don't have a chainId or account
|
|
function applyChainAgnosticFix(approvedNamespaces) {
|
|
try {
|
|
const an = approvedNamespaces.eip155
|
|
const chainAgnosticRequest = (!an.chains || an.chains.length === 0) && (!an.accounts || an.accounts.length === 0)
|
|
if (!chainAgnosticRequest) {
|
|
return approvedNamespaces
|
|
}
|
|
|
|
// If the `d.acceptedNamespaces` is set it means the user already confirmed the chain and account
|
|
if (!!d.acceptedNamespaces) {
|
|
approvedNamespaces.eip155.chains = d.acceptedNamespaces.eip155.chains
|
|
approvedNamespaces.eip155.accounts = d.acceptedNamespaces.eip155.accounts
|
|
return approvedNamespaces
|
|
}
|
|
|
|
// Show to the user all possible chains
|
|
const supportedNamespacesStr = DAppsHelpers.buildSupportedNamespacesFromModels(
|
|
root.networksModel, root.accountsModel, SessionRequest.getSupportedMethods())
|
|
const supportedNamespaces = JSON.parse(supportedNamespacesStr)
|
|
|
|
approvedNamespaces.eip155.chains = supportedNamespaces.eip155.chains
|
|
approvedNamespaces.eip155.accounts = supportedNamespaces.eip155.accounts
|
|
} catch (e) {
|
|
console.warn("WC Error applying chain agnostic fix", e)
|
|
}
|
|
|
|
return approvedNamespaces
|
|
}
|
|
|
|
function onSessionProposal(sessionProposal) {
|
|
const key = sessionProposal.id
|
|
d.activeProposals.set(key, sessionProposal)
|
|
|
|
const supportedNamespacesStr = DAppsHelpers.buildSupportedNamespacesFromModels(
|
|
root.networksModel, root.accountsModel, SessionRequest.getSupportedMethods())
|
|
sdk.buildApprovedNamespaces(key, sessionProposal.params, JSON.parse(supportedNamespacesStr))
|
|
}
|
|
|
|
function onPairResponse(ok) {
|
|
root.pairingResponse(ok)
|
|
}
|
|
|
|
function onSessionRequestEvent(event) {
|
|
const res = d.resolveAsync(event)
|
|
if (res.code == d.resolveAsyncResult.error) {
|
|
let error = true
|
|
sdk.rejectSessionRequest(event.topic, event.id, error)
|
|
return
|
|
}
|
|
if (res.code == d.resolveAsyncResult.ignored) {
|
|
return
|
|
}
|
|
if (!res.obj) {
|
|
console.error("Unexpected res.obj value!")
|
|
return
|
|
}
|
|
requests.enqueue(res.obj)
|
|
}
|
|
|
|
function onSessionRequestUserAnswerResult(topic, id, accept, error) {
|
|
let request = requests.findRequest(topic, id)
|
|
if (request === null) {
|
|
console.error("Error finding event for topic", topic, "id", id)
|
|
return
|
|
}
|
|
|
|
let methodStr = SessionRequest.methodToUserString(request.method)
|
|
if (!methodStr) {
|
|
console.error("Error finding user string for method", request.method)
|
|
return
|
|
}
|
|
|
|
const appUrl = request.dappUrl
|
|
const appDomain = SQUtils.StringUtils.extractDomainFromLink(appUrl)
|
|
const requestExpired = request.isExpired()
|
|
|
|
requests.removeRequest(topic, id)
|
|
|
|
if (error) {
|
|
root.displayToastMessage(qsTr("Fail to %1 from %2").arg(methodStr).arg(appDomain), Constants.ephemeralNotificationType.danger)
|
|
sdk.rejectSessionRequest(topic, id, true /*hasError*/)
|
|
console.error(`Error accepting session request for topic: ${topic}, id: ${id}, accept: ${accept}, error: ${error}`)
|
|
return
|
|
}
|
|
|
|
if (!requestExpired) {
|
|
let actionStr = accept ? qsTr("accepted") : qsTr("rejected")
|
|
root.displayToastMessage("%1 %2 %3".arg(appDomain).arg(methodStr).arg(actionStr), Constants.ephemeralNotificationType.success)
|
|
return
|
|
}
|
|
|
|
root.displayToastMessage("%1 sign request timed out".arg(appDomain), Constants.ephemeralNotificationType.normal)
|
|
}
|
|
|
|
function onSessionRequestExpired(sessionId) {
|
|
// Expired event coming from WC
|
|
// Handling as a failsafe in case the event is not processed by the SDK
|
|
let request = requests.findById(sessionId)
|
|
if (request === null) {
|
|
console.error("Error finding event for session id", sessionId)
|
|
return
|
|
}
|
|
|
|
if (request.isExpired()) {
|
|
return //nothing to do. The request is already expired
|
|
}
|
|
|
|
request.setExpired()
|
|
}
|
|
|
|
function onSessionDelete(topic, err) {
|
|
d.disconnectSessionRequested(topic, err)
|
|
}
|
|
}
|
|
|
|
SiweRequestPlugin {
|
|
id: siwePlugin
|
|
|
|
sdk: root.sdk
|
|
store: root.store
|
|
accountsModel: root.accountsModel
|
|
networksModel: root.networksModel
|
|
|
|
onRegisterSignRequest: (request) => {
|
|
requests.enqueue(request)
|
|
}
|
|
|
|
onUnregisterSignRequest: (requestId) => {
|
|
const request = requests.findById(requestId)
|
|
if (request === null) {
|
|
console.error("SiweRequestPlugin::onUnregisterSignRequest: Error finding event for requestId", requestId)
|
|
return
|
|
}
|
|
requests.removeRequest(request.topic, requestId)
|
|
}
|
|
|
|
onConnectDApp: (chains, dAppUrl, dAppName, dAppIcon, key) => {
|
|
root.connectDApp(chains, dAppUrl, dAppName, dAppIcon, key)
|
|
}
|
|
|
|
onSiweFailed: (id, error, topic) => {
|
|
root.approveSessionResult(id, error, topic)
|
|
}
|
|
|
|
onSiweSuccessful: (id, topic) => {
|
|
d.lookupSession(topic, function(session) {
|
|
// TODO #14754: implement custom dApp notification
|
|
let meta = session.peer.metadata
|
|
const dappUrl = meta.url ?? "-"
|
|
const dappDomain = SQUtils.StringUtils.extractDomainFromLink(dappUrl)
|
|
root.displayToastMessage(qsTr("Connected to %1 via WalletConnect").arg(dappDomain), Constants.ephemeralNotificationType.success)
|
|
|
|
// Persist session
|
|
if(!root.store.addWalletConnectSession(JSON.stringify(session))) {
|
|
console.error("Failed to persist session")
|
|
}
|
|
|
|
root.approveSessionResult(id, "", topic)
|
|
})
|
|
}
|
|
}
|
|
|
|
SQUtils.QObject {
|
|
id: d
|
|
|
|
property int selectedFeesMode: Constants.FeesMode.Medium
|
|
|
|
function getFeesForFeesMode(feesObj) {
|
|
if (!(feesObj.hasOwnProperty("maxFeePerGasL") &&
|
|
feesObj.hasOwnProperty("maxFeePerGasM") &&
|
|
feesObj.hasOwnProperty("maxFeePerGasH"))) {
|
|
throw new Error("inappropriate fees object provided")
|
|
}
|
|
|
|
switch (d.selectedFeesMode) {
|
|
case Constants.FeesMode.Low:
|
|
return feesObj.maxFeePerGasL
|
|
case Constants.FeesMode.Medium:
|
|
return feesObj.maxFeePerGasM
|
|
case Constants.FeesMode.High:
|
|
return feesObj.maxFeePerGasH
|
|
default:
|
|
throw new Error("unknown selected mode")
|
|
}
|
|
}
|
|
|
|
property var feesSubscriptions: []
|
|
|
|
function findSubscriptionIndex(topic, id) {
|
|
for (let i = 0; i < d.feesSubscriptions.length; i++) {
|
|
const subscription = d.feesSubscriptions[i]
|
|
if (subscription.topic == topic && subscription.id == id) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
function findChainIndex(chainId) {
|
|
for (let i = 0; i < feesSubscription.chainIds.length; i++) {
|
|
if (feesSubscription.chainIds[i] == chainId) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
function subscribeForFeeUpdates(topic, id) {
|
|
const request = requests.findRequest(topic, id)
|
|
if (request === null) {
|
|
console.error("Error finding event for subscribing for fees for topic", topic, "id", id)
|
|
return
|
|
}
|
|
|
|
const index = d.findSubscriptionIndex(topic, id)
|
|
if (index >= 0) {
|
|
return
|
|
}
|
|
|
|
d.feesSubscriptions.push({
|
|
topic: topic,
|
|
id: id,
|
|
chainId: request.chainId
|
|
})
|
|
|
|
for (let i = 0; i < feesSubscription.chainIds.length; i++) {
|
|
if (feesSubscription.chainIds == request.chainId) {
|
|
return
|
|
}
|
|
}
|
|
|
|
feesSubscription.chainIds.push(request.chainId)
|
|
feesSubscription.restart()
|
|
}
|
|
|
|
function unsubscribeForFeeUpdates(topic, id) {
|
|
const index = d.findSubscriptionIndex(topic, id)
|
|
if (index == -1) {
|
|
return
|
|
}
|
|
|
|
const chainId = d.feesSubscriptions[index].chainId
|
|
d.feesSubscriptions.splice(index, 1)
|
|
|
|
const chainIndex = d.findChainIndex(chainId)
|
|
if (index == -1) {
|
|
return
|
|
}
|
|
|
|
let found = false
|
|
for (let i = 0; i < d.feesSubscriptions.length; i++) {
|
|
if (d.feesSubscriptions[i].chainId == chainId) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (found) {
|
|
return
|
|
}
|
|
|
|
feesSubscription.chainIds.splice(chainIndex, 1)
|
|
if (feesSubscription.chainIds.length == 0) {
|
|
feesSubscription.stop()
|
|
}
|
|
}
|
|
|
|
readonly property QtObject resolveAsyncResult: QtObject {
|
|
readonly property int error: 0
|
|
readonly property int ok: 1
|
|
readonly property int ignored: 2
|
|
}
|
|
|
|
property var activeProposals: new Map() // key: proposalId, value: sessionProposal
|
|
property var acceptedSessionProposal: null
|
|
property var acceptedNamespaces: null
|
|
|
|
// returns {
|
|
// obj: obj or nil
|
|
// code: resolveAsyncResult codes
|
|
// }
|
|
function resolveAsync(event) {
|
|
const method = event.params.request.method
|
|
const { accountAddress, success } = lookupAccountFromEvent(event, method)
|
|
if(!success) {
|
|
console.info("Error finding accountAddress for event", JSON.stringify(event))
|
|
return { obj: null, code: resolveAsyncResult.error }
|
|
}
|
|
|
|
if (!accountAddress) {
|
|
console.info("Account not found for event", JSON.stringify(event))
|
|
return { obj: null, code: resolveAsyncResult.ignored }
|
|
}
|
|
|
|
let chainId = lookupNetworkFromEvent(event, method)
|
|
if(!chainId) {
|
|
console.error("Error finding chainId for event", JSON.stringify(event))
|
|
return { obj: null, code: resolveAsyncResult.error }
|
|
}
|
|
|
|
const data = extractMethodData(event, method)
|
|
if(!data) {
|
|
console.error("Error in event data lookup", JSON.stringify(event))
|
|
return { obj: null, code: resolveAsyncResult.error }
|
|
}
|
|
|
|
const interpreted = d.prepareData(method, data, chainId)
|
|
|
|
const enoughFunds = !d.isTransactionMethod(method)
|
|
const requestExpiry = event.params.request.expiryTimestamp
|
|
|
|
let obj = sessionRequestComponent.createObject(null, {
|
|
event,
|
|
topic: event.topic,
|
|
requestId: event.id,
|
|
method,
|
|
accountAddress,
|
|
chainId,
|
|
data,
|
|
preparedData: interpreted.preparedData,
|
|
maxFeesText: "?",
|
|
maxFeesEthText: "?",
|
|
expirationTimestamp: requestExpiry
|
|
})
|
|
if (obj === null) {
|
|
console.error("Error creating SessionRequestResolved for event")
|
|
return { obj: null, code: resolveAsyncResult.error }
|
|
}
|
|
|
|
// Check later to have a valid request object
|
|
if (!SessionRequest.getSupportedMethods().includes(method)) {
|
|
console.error("Unsupported method", method)
|
|
return { obj: null, code: resolveAsyncResult.error }
|
|
}
|
|
|
|
d.lookupSession(obj.topic, function(session) {
|
|
if (session === null) {
|
|
console.error("DAppsRequestHandler.lookupSession: error finding session for topic", obj.topic)
|
|
return
|
|
}
|
|
|
|
obj.resolveDappInfoFromSession(session)
|
|
root.sessionRequest(obj.requestId)
|
|
|
|
d.updateFeesParamsToPassedObj(obj)
|
|
})
|
|
|
|
return {
|
|
obj: obj,
|
|
code: resolveAsyncResult.ok
|
|
}
|
|
}
|
|
|
|
function updateFeesParamsToPassedObj(obj) {
|
|
if (!d.isTransactionMethod(obj.method)) {
|
|
return
|
|
}
|
|
|
|
obj.estimatedTimeCategory = getEstimatedTimeInterval(obj.data, obj.method, obj.chainId)
|
|
|
|
const mainNet = lookupMainnetNetwork()
|
|
let mainChainId = obj.chainId
|
|
if (!!mainNet) {
|
|
mainChainId = mainNet.chainId
|
|
} else {
|
|
console.error("Error finding mainnet network")
|
|
}
|
|
|
|
const interpreted = d.prepareData(obj.method, obj.data, obj.chainId)
|
|
|
|
let st = getEstimatedFeesStatus(obj.data, obj.method, obj.chainId, mainChainId)
|
|
let fundsStatus = checkFundsStatus(st.feesInfo.maxFees, st.feesInfo.l1GasFee, obj.accountAddress, obj.chainId, mainNet.chainId, interpreted.value)
|
|
obj.fiatMaxFees = st.fiatMaxFees
|
|
obj.ethMaxFees = st.maxFeesEth
|
|
obj.haveEnoughFunds = fundsStatus.haveEnoughFunds
|
|
obj.haveEnoughFees = fundsStatus.haveEnoughForFees
|
|
obj.feesInfo = st.feesInfo
|
|
}
|
|
|
|
/// returns {
|
|
/// accountAddress
|
|
/// success
|
|
/// }
|
|
/// if account is null and success is true it means that the account was not found
|
|
function lookupAccountFromEvent(event, method) {
|
|
let address = ""
|
|
if (method === SessionRequest.methods.personalSign.name) {
|
|
if (event.params.request.params.length < 2) {
|
|
return { accountAddress: "", success: false }
|
|
}
|
|
address = event.params.request.params[1]
|
|
} else if (method === SessionRequest.methods.sign.name) {
|
|
if (event.params.request.params.length === 1) {
|
|
return { accountAddress: "", success: false }
|
|
}
|
|
address = event.params.request.params[0]
|
|
} else if(method === SessionRequest.methods.signTypedData_v4.name ||
|
|
method === SessionRequest.methods.signTypedData.name)
|
|
{
|
|
if (event.params.request.params.length < 2) {
|
|
return { accountAddress: "", success: false }
|
|
}
|
|
address = event.params.request.params[0]
|
|
} else if (d.isTransactionMethod(method)) {
|
|
if (event.params.request.params.length == 0) {
|
|
return { accountAddress: "", success: false }
|
|
}
|
|
address = event.params.request.params[0].from
|
|
} else {
|
|
console.error("Unsupported method to lookup account: ", method)
|
|
return { accountAddress: "", success: false }
|
|
}
|
|
const account = SQUtils.ModelUtils.getFirstModelEntryIf(root.accountsModel, (account) => {
|
|
return account.address.toLowerCase() === address.toLowerCase();
|
|
})
|
|
|
|
if (!account) {
|
|
return { accountAddress: "", success: true }
|
|
}
|
|
|
|
return { accountAddress: account.address, success: true }
|
|
}
|
|
|
|
/// Returns null if the network is not found
|
|
function lookupNetworkFromEvent(event, method) {
|
|
if (SessionRequest.getSupportedMethods().includes(method) === false) {
|
|
return null
|
|
}
|
|
const chainId = DAppsHelpers.chainIdFromEip155(event.params.chainId)
|
|
const network = SQUtils.ModelUtils.getByKey(root.networksModel, "chainId", chainId)
|
|
|
|
if (!network) {
|
|
return null
|
|
}
|
|
|
|
return network.chainId
|
|
}
|
|
|
|
/// Returns null if the network is not found
|
|
function lookupMainnetNetwork() {
|
|
return SQUtils.ModelUtils.getByKey(root.networksModel, "layer", 1)
|
|
}
|
|
|
|
function extractMethodData(event, method) {
|
|
if (method === SessionRequest.methods.personalSign.name ||
|
|
method === SessionRequest.methods.sign.name)
|
|
{
|
|
if (event.params.request.params.length < 1) {
|
|
return null
|
|
}
|
|
let message = ""
|
|
const messageIndex = (method === SessionRequest.methods.personalSign.name ? 0 : 1)
|
|
const messageParam = event.params.request.params[messageIndex]
|
|
// There is no standard on how data is encoded. Therefore we support hex or utf8
|
|
if (DAppsHelpers.isHex(messageParam)) {
|
|
message = DAppsHelpers.hexToString(messageParam)
|
|
} else {
|
|
message = messageParam
|
|
}
|
|
return SessionRequest.methods.personalSign.buildDataObject(message)
|
|
} else if (method === SessionRequest.methods.signTypedData_v4.name ||
|
|
method === SessionRequest.methods.signTypedData.name)
|
|
{
|
|
if (event.params.request.params.length < 2) {
|
|
return null
|
|
}
|
|
const jsonMessage = event.params.request.params[1]
|
|
const methodObj = method === SessionRequest.methods.signTypedData_v4.name
|
|
? SessionRequest.methods.signTypedData_v4
|
|
: SessionRequest.methods.signTypedData
|
|
return methodObj.buildDataObject(jsonMessage)
|
|
} else if (method === SessionRequest.methods.signTransaction.name) {
|
|
if (event.params.request.params.length == 0) {
|
|
return null
|
|
}
|
|
const tx = event.params.request.params[0]
|
|
return SessionRequest.methods.signTransaction.buildDataObject(tx)
|
|
} else if (method === SessionRequest.methods.sendTransaction.name) {
|
|
if (event.params.request.params.length == 0) {
|
|
return null
|
|
}
|
|
const tx = event.params.request.params[0]
|
|
return SessionRequest.methods.sendTransaction.buildDataObject(tx)
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function lookupSession(topicToLookup, callback) {
|
|
sdk.getActiveSessions((res) => {
|
|
Object.keys(res).forEach((topic) => {
|
|
if (topic === topicToLookup) {
|
|
let session = res[topic]
|
|
callback(session)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
function executeSessionRequest(request, password, pin, payload) {
|
|
if (!SessionRequest.getSupportedMethods().includes(request.method)) {
|
|
console.error("Unsupported method to execute: ", request.method)
|
|
return false
|
|
}
|
|
|
|
if (password === "") {
|
|
console.error("No password provided to sign message")
|
|
return false
|
|
}
|
|
|
|
if (request.method === SessionRequest.methods.sign.name) {
|
|
store.signMessageUnsafe(request.topic,
|
|
request.requestId,
|
|
request.accountAddress,
|
|
SessionRequest.methods.personalSign.getMessageFromData(request.data),
|
|
password,
|
|
pin)
|
|
} else if (request.method === SessionRequest.methods.personalSign.name) {
|
|
store.signMessage(request.topic,
|
|
request.requestId,
|
|
request.accountAddress,
|
|
SessionRequest.methods.personalSign.getMessageFromData(request.data),
|
|
password,
|
|
pin)
|
|
} else if (request.method === SessionRequest.methods.signTypedData_v4.name ||
|
|
request.method === SessionRequest.methods.signTypedData.name)
|
|
{
|
|
let legacy = request.method === SessionRequest.methods.signTypedData.name
|
|
store.safeSignTypedData(request.topic,
|
|
request.requestId,
|
|
request.accountAddress,
|
|
SessionRequest.methods.signTypedData.getMessageFromData(request.data),
|
|
request.chainId,
|
|
legacy,
|
|
password,
|
|
pin)
|
|
} else if (d.isTransactionMethod(request.method)) {
|
|
let txObj = d.getTxObject(request.method, request.data)
|
|
if (!!payload) {
|
|
let feesInfoJson = payload
|
|
let hexFeesJson = root.store.convertFeesInfoToHex(feesInfoJson)
|
|
if (!!hexFeesJson) {
|
|
let feesInfo = JSON.parse(hexFeesJson)
|
|
if (feesInfo.maxFeePerGas) {
|
|
txObj.maxFeePerGas = feesInfo.maxFeePerGas
|
|
}
|
|
if (feesInfo.maxPriorityFeePerGas) {
|
|
txObj.maxPriorityFeePerGas = feesInfo.maxPriorityFeePerGas
|
|
}
|
|
}
|
|
delete txObj.gasLimit
|
|
delete txObj.gasPrice
|
|
}
|
|
// Remove nonce from txObj to be auto-filled by the wallet
|
|
delete txObj.nonce
|
|
|
|
if (request.method === SessionRequest.methods.signTransaction.name) {
|
|
store.signTransaction(request.topic,
|
|
request.requestId,
|
|
request.accountAddress,
|
|
request.chainId,
|
|
txObj,
|
|
password,
|
|
pin)
|
|
} else if (request.method === SessionRequest.methods.sendTransaction.name) {
|
|
store.sendTransaction(
|
|
request.topic,
|
|
request.requestId,
|
|
request.accountAddress,
|
|
request.chainId,
|
|
txObj,
|
|
password,
|
|
pin)
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Returns Constants.TransactionEstimatedTime
|
|
function getEstimatedTimeInterval(data, method, chainId) {
|
|
let tx = {}
|
|
let maxFeePerGas = ""
|
|
if (d.isTransactionMethod(method)) {
|
|
tx = d.getTxObject(method, data)
|
|
// Empty string instructs getEstimatedTime to fetch the blockchain value
|
|
if (!!tx.maxFeePerGas) {
|
|
maxFeePerGas = tx.maxFeePerGas
|
|
} else if (!!tx.gasPrice) {
|
|
maxFeePerGas = tx.gasPrice
|
|
}
|
|
}
|
|
|
|
return root.store.getEstimatedTime(chainId, maxFeePerGas)
|
|
}
|
|
|
|
// Returns {
|
|
// maxFees -> Big number in Gwei
|
|
// maxFeePerGas
|
|
// maxPriorityFeePerGas
|
|
// gasPrice
|
|
// }
|
|
function getEstimatedMaxFees(data, method, chainId, mainNetChainId) {
|
|
let tx = {}
|
|
if (d.isTransactionMethod(method)) {
|
|
tx = d.getTxObject(method, data)
|
|
}
|
|
|
|
let BigOps = SQUtils.AmountsArithmetic
|
|
let gasLimit = BigOps.fromString("21000")
|
|
let gasPrice, maxFeePerGas, maxPriorityFeePerGas
|
|
let l1GasFee = BigOps.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
|
|
if (!!tx.maxFeePerGas && !!tx.maxPriorityFeePerGas) {
|
|
maxFeePerGas = hexToGwei(tx.maxFeePerGas)
|
|
maxPriorityFeePerGas = hexToGwei(tx.maxPriorityFeePerGas)
|
|
|
|
// TODO: check why we need to set gasPrice here and why if it's not checked we cannot send the tx and fees are unknown????
|
|
gasPrice = hexToGwei(tx.maxFeePerGas)
|
|
} else {
|
|
let fees = root.store.getSuggestedFees(chainId)
|
|
maxPriorityFeePerGas = fees.maxPriorityFeePerGas
|
|
if (fees.eip1559Enabled) {
|
|
if (!!fees.maxFeePerGasM) {
|
|
gasPrice = BigOps.fromNumber(fees.maxFeePerGasM)
|
|
maxFeePerGas = fees.maxFeePerGasM
|
|
} else if(!!tx.maxFeePerGas) {
|
|
let maxFeePerGasDec = root.store.hexToDec(tx.maxFeePerGas)
|
|
gasPrice = BigOps.fromString(maxFeePerGasDec)
|
|
maxFeePerGas = maxFeePerGasDec
|
|
} else {
|
|
console.error("Error fetching maxFeePerGas from fees or tx objects")
|
|
return
|
|
}
|
|
} else {
|
|
if (!!fees.gasPrice) {
|
|
gasPrice = BigOps.fromNumber(fees.gasPrice)
|
|
} else {
|
|
console.error("Error fetching suggested fees")
|
|
return
|
|
}
|
|
}
|
|
l1GasFee = BigOps.fromNumber(fees.l1GasFee)
|
|
}
|
|
|
|
let maxFees = BigOps.times(gasLimit, gasPrice)
|
|
return {maxFees, maxFeePerGas, maxPriorityFeePerGas, gasPrice, l1GasFee}
|
|
}
|
|
|
|
// Returned values are Big numbers
|
|
function getEstimatedFeesStatus(data, method, chainId, mainNetChainId) {
|
|
let BigOps = SQUtils.AmountsArithmetic
|
|
|
|
let feesInfo = getEstimatedMaxFees(data, method, chainId, mainNetChainId)
|
|
|
|
let totalMaxFees = BigOps.sum(feesInfo.maxFees, feesInfo.l1GasFee)
|
|
let maxFeesEth = BigOps.div(totalMaxFees, BigOps.fromNumber(1, 9))
|
|
|
|
let maxFeesEthStr = maxFeesEth.toString()
|
|
let fiatMaxFeesStr = root.currenciesStore.getFiatValue(maxFeesEthStr, Constants.ethToken)
|
|
let fiatMaxFees = BigOps.fromString(fiatMaxFeesStr)
|
|
let symbol = root.currenciesStore.currentCurrency
|
|
|
|
return {fiatMaxFees, maxFeesEth, symbol, feesInfo}
|
|
}
|
|
|
|
function getBalanceInEth(balances, address, chainId) {
|
|
const BigOps = 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 null
|
|
}
|
|
let accountFundsWei = BigOps.fromString(accEth.balance)
|
|
return BigOps.div(accountFundsWei, BigOps.fromNumber(1, 18))
|
|
}
|
|
|
|
// Returns {haveEnoughForFees, haveEnoughFunds} and true in case of error not to block request
|
|
function checkFundsStatus(maxFees, l1GasFee, address, chainId, mainNetChainId, valueEth) {
|
|
let BigOps = SQUtils.AmountsArithmetic
|
|
|
|
let haveEnoughForFees = true
|
|
let haveEnoughFunds = true
|
|
|
|
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}
|
|
}
|
|
|
|
let chainBalance = getBalanceInEth(token.balances, address, chainId)
|
|
if (!chainBalance) {
|
|
console.error("Error fetching chain balance")
|
|
return {haveEnoughForFees, haveEnoughFunds}
|
|
}
|
|
haveEnoughFunds = BigOps.cmp(chainBalance, valueEth) >= 0
|
|
if (haveEnoughFunds) {
|
|
chainBalance = BigOps.sub(chainBalance, valueEth)
|
|
|
|
if (chainId == mainNetChainId) {
|
|
const finalFees = BigOps.sum(maxFees, l1GasFee)
|
|
let feesEth = BigOps.div(finalFees, BigOps.fromNumber(1, 9))
|
|
haveEnoughForFees = BigOps.cmp(chainBalance, feesEth) >= 0
|
|
} else {
|
|
const feesChain = BigOps.div(maxFees, BigOps.fromNumber(1, 9))
|
|
const haveEnoughOnChain = BigOps.cmp(chainBalance, feesChain) >= 0
|
|
|
|
const mainBalance = getBalanceInEth(token.balances, address, mainNetChainId)
|
|
if (!mainBalance) {
|
|
console.error("Error fetching mainnet balance")
|
|
return {haveEnoughForFees, haveEnoughFunds}
|
|
}
|
|
const feesMain = BigOps.div(l1GasFee, BigOps.fromNumber(1, 9))
|
|
const haveEnoughOnMain = BigOps.cmp(mainBalance, feesMain) >= 0
|
|
|
|
haveEnoughForFees = haveEnoughOnChain && haveEnoughOnMain
|
|
}
|
|
} else {
|
|
haveEnoughForFees = false
|
|
}
|
|
|
|
return {haveEnoughForFees, haveEnoughFunds}
|
|
}
|
|
|
|
function isTransactionMethod(method) {
|
|
return method === SessionRequest.methods.signTransaction.name
|
|
|| method === SessionRequest.methods.sendTransaction.name
|
|
}
|
|
|
|
function getTxObject(method, data) {
|
|
let tx
|
|
if (method === SessionRequest.methods.signTransaction.name) {
|
|
tx = SessionRequest.methods.signTransaction.getTxObjFromData(data)
|
|
} else if (method === SessionRequest.methods.sendTransaction.name) {
|
|
tx = SessionRequest.methods.sendTransaction.getTxObjFromData(data)
|
|
} else {
|
|
console.error("Not a transaction method")
|
|
}
|
|
return tx
|
|
}
|
|
|
|
// returns {
|
|
// preparedData,
|
|
// value // null or ETH Big number
|
|
// }
|
|
function prepareData(method, data, chainId) {
|
|
let payload = null
|
|
switch(method) {
|
|
case SessionRequest.methods.personalSign.name: {
|
|
payload = SessionRequest.methods.personalSign.getMessageFromData(data)
|
|
break
|
|
}
|
|
case SessionRequest.methods.sign.name: {
|
|
payload = SessionRequest.methods.sign.getMessageFromData(data)
|
|
break
|
|
}
|
|
case SessionRequest.methods.signTypedData_v4.name: {
|
|
const stringPayload = SessionRequest.methods.signTypedData_v4.getMessageFromData(data)
|
|
payload = JSON.stringify(JSON.parse(stringPayload), null, 2)
|
|
break
|
|
}
|
|
case SessionRequest.methods.signTypedData.name: {
|
|
const stringPayload = SessionRequest.methods.signTypedData.getMessageFromData(data)
|
|
payload = JSON.stringify(JSON.parse(stringPayload), null, 2)
|
|
break
|
|
}
|
|
case SessionRequest.methods.signTransaction.name:
|
|
case SessionRequest.methods.sendTransaction.name:
|
|
// For transactions we process the data in a different way as follows
|
|
break
|
|
default:
|
|
console.error("Unhandled method", method)
|
|
break;
|
|
}
|
|
|
|
let value = SQUtils.AmountsArithmetic.fromNumber(0)
|
|
if (d.isTransactionMethod(method)) {
|
|
let txObj = d.getTxObject(method, data)
|
|
let tx = Object.assign({}, txObj)
|
|
let fees = root.store.getSuggestedFees(chainId)
|
|
if (tx.value) {
|
|
value = hexToEth(tx.value)
|
|
tx.value = value.toString()
|
|
}
|
|
|
|
if (tx.hasOwnProperty("maxFeePerGas")) {
|
|
if (tx.maxFeePerGas) {
|
|
tx.maxFeePerGas = hexToGwei(tx.maxFeePerGas).toString()
|
|
} else if (fees.eip1559Enabled) {
|
|
try {
|
|
tx.maxFeePerGas = d.getFeesForFeesMode(fees)
|
|
} catch (e) {
|
|
console.warn(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (tx.hasOwnProperty("maxPriorityFeePerGas")) {
|
|
if (tx.maxPriorityFeePerGas) {
|
|
tx.maxPriorityFeePerGas = hexToGwei(tx.maxPriorityFeePerGas).toString()
|
|
} else if (fees.eip1559Enabled) {
|
|
tx.maxPriorityFeePerGas = fees.maxPriorityFeePerGas
|
|
}
|
|
}
|
|
|
|
if (tx.hasOwnProperty("gasPrice")) {
|
|
if (tx.gasPrice) {
|
|
tx.gasPrice = hexToGwei(tx.gasPrice)
|
|
} else if (!fees.eip1559Enabled) {
|
|
tx.gasPrice = fees.gasPrice
|
|
}
|
|
}
|
|
|
|
if (tx.gasLimit) {
|
|
tx.gasLimit = parseInt(root.store.hexToDec(tx.gasLimit))
|
|
}
|
|
|
|
if (tx.nonce) {
|
|
tx.nonce = parseInt(root.store.hexToDec(tx.nonce))
|
|
}
|
|
|
|
payload = JSON.stringify(tx, null, 2)
|
|
}
|
|
return {
|
|
preparedData: payload,
|
|
value: value
|
|
}
|
|
}
|
|
|
|
function hexToEth(value) {
|
|
return hexToEthDenomination(value, "eth")
|
|
}
|
|
function hexToGwei(value) {
|
|
return hexToEthDenomination(value, "gwei")
|
|
}
|
|
function hexToEthDenomination(value, ethUnit) {
|
|
let unitMapping = {
|
|
"gwei": 9,
|
|
"eth": 18
|
|
}
|
|
let BigOps = SQUtils.AmountsArithmetic
|
|
let decValue = root.store.hexToDec(value)
|
|
if (!!decValue) {
|
|
return BigOps.div(BigOps.fromNumber(decValue), BigOps.fromNumber(1, unitMapping[ethUnit]))
|
|
}
|
|
return BigOps.fromNumber(0)
|
|
}
|
|
|
|
function disconnectSessionRequested(topic, err) {
|
|
// Get all sessions and filter the active ones for known accounts
|
|
// Act on the first matching session with the same topic
|
|
const activeSessionsCallback = (allSessions, success) => {
|
|
root.store.activeSessionsReceived.disconnect(activeSessionsCallback)
|
|
|
|
if (!success) {
|
|
// TODO #14754: implement custom dApp notification
|
|
root.dappDisconnected("", "", true)
|
|
return
|
|
}
|
|
|
|
// Convert to original format
|
|
const webSdkSessions = allSessions.map((session) => {
|
|
return JSON.parse(session.sessionJson)
|
|
})
|
|
|
|
const sessions = DAppsHelpers.filterActiveSessionsForKnownAccounts(webSdkSessions, root.accountsModel)
|
|
|
|
for (const sessionID in sessions) {
|
|
const session = sessions[sessionID]
|
|
if (session.topic == topic) {
|
|
root.store.deactivateWalletConnectSession(topic)
|
|
|
|
const dappUrl = session.peer.metadata.url ?? "-"
|
|
root.dappDisconnected(topic, dappUrl, err)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
root.store.activeSessionsReceived.connect(activeSessionsCallback)
|
|
if (!root.store.getActiveSessions()) {
|
|
root.store.activeSessionsReceived.disconnect(activeSessionsCallback)
|
|
// TODO #14754: implement custom dApp notification
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The queue is used to ensure that the events are processed in the order they are received but they could be
|
|
/// processed handled randomly on user intervention through activity center
|
|
SessionRequestsModel {
|
|
id: requests
|
|
}
|
|
|
|
Component {
|
|
id: sessionRequestComponent
|
|
|
|
SessionRequestWithAuth {
|
|
id: request
|
|
sourceId: Constants.DAppConnectors.WalletConnect
|
|
store: root.store
|
|
|
|
function signedHandler(topic, id, data) {
|
|
if (topic != request.topic || id != request.requestId) {
|
|
return
|
|
}
|
|
root.store.signingResult.disconnect(request.signedHandler)
|
|
|
|
let hasErrors = (data == "")
|
|
if (!hasErrors) {
|
|
// acceptSessionRequest will trigger an sdk.sessionRequestUserAnswerResult signal
|
|
sdk.acceptSessionRequest(topic, id, data)
|
|
} else {
|
|
request.reject(true)
|
|
}
|
|
}
|
|
|
|
onAccepted: () => {
|
|
d.unsubscribeForFeeUpdates(request.topic, request.requestId)
|
|
}
|
|
|
|
onRejected: (hasError) => {
|
|
d.unsubscribeForFeeUpdates(request.topic, request.requestId)
|
|
sdk.rejectSessionRequest(request.topic, request.requestId, hasError)
|
|
}
|
|
|
|
onAuthFailed: () => {
|
|
d.unsubscribeForFeeUpdates(request.topic, request.requestId)
|
|
sdk.rejectSessionRequest(request.topic, request.requestId, true)
|
|
const appDomain = SQUtils.StringUtils.extractDomainFromLink(request.dappUrl)
|
|
const methodStr = SessionRequest.methodToUserString(request.method)
|
|
if (!methodStr) {
|
|
return
|
|
}
|
|
root.displayToastMessage(qsTr("Failed to authenticate %1 from %2").arg(methodStr).arg(appDomain), Constants.ephemeralNotificationType.danger)
|
|
}
|
|
|
|
onExecute: (password, pin) => {
|
|
root.store.signingResult.connect(request.signedHandler)
|
|
let executed = false
|
|
try {
|
|
executed = d.executeSessionRequest(request, password, pin, request.feesInfo)
|
|
} catch (e) {
|
|
console.error("Error executing session request", e)
|
|
}
|
|
|
|
if (!executed) {
|
|
sdk.rejectSessionRequest(request.topic, request.requestId, true /*hasError*/)
|
|
root.store.signingResult.disconnect(request.signedHandler)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: feesSubscription
|
|
|
|
property var chainIds: []
|
|
|
|
interval: 5000
|
|
repeat: true
|
|
running: Qt.application.state === Qt.ApplicationActive
|
|
|
|
onTriggered: {
|
|
for (let i = 0; i < chainIds.length; i++) {
|
|
for (let j = 0; j < d.feesSubscriptions.length; j++) {
|
|
let subscription = d.feesSubscriptions[j]
|
|
if (subscription.chainId == chainIds[i]) {
|
|
let request = requests.findRequest(subscription.topic, subscription.id)
|
|
if (request === null) {
|
|
console.error("Error updating fees for topic", subscription.topic, "id", subscription.id)
|
|
continue
|
|
}
|
|
d.updateFeesParamsToPassedObj(request)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ChainsSupervisorPlugin {
|
|
id: chainsSupervisorPlugin
|
|
|
|
sdk: root.sdk
|
|
networksModel: root.networksModel
|
|
}
|
|
}
|