Alex Jbanca fd99b96cb5 feat(WalletConnect): Handle sign request expiration
Implementing the user-story for sign request expiry and add qml tests
+ other minor fixes

## Acceptance Criteria

```
//Always show the expiration
Given the sign/transaction request dialog is shown
When request has an expiration date
Then the user sees a 1 minute countdown in the dialog
```

```
// Show 1 minute timer
Given the sign/transaction request dialog is shown
When the request has 1 minute or less before expiring
Then the user sees a 1 second countdown in the dialog
```

```
Given the sign/transaction dialog is open
When the request expires
Then the Accept button is removed
And the only option for the user is to close the dialog
```

```
Given the sign/transaction request dialog is open
When the request expired
Then the `Sign` and `Reject` buttons are removed
And the `Close` button is visible
```

```
Given the sign/transaction request expired
Then a toast message is showing
And it contains the "<dapp domain> sign request timed out" message
```

```
Given the sign/transaction request dialog is open
When the request expired
Then the sign/transaction request dialog is still visible
```

```
Given the sign/transaction request expires
Then a console message is shown
And it contains 'WC WalletConnectSDK.onSessionRequestExpire; id: ${id}`'
```
2024-10-10 12:49:15 +03:00

798 lines
31 KiB
QML

import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtWebEngine 1.10
import QtWebChannel 1.15
import StatusQ.Core.Utils 0.1 as SQUtils
import StatusQ.Components 0.1
import StatusQ 0.1
import SortFilterProxyModel 0.2
import AppLayouts.Wallet.controls 1.0
import shared.popups.walletconnect 1.0
import AppLayouts.Wallet.services.dapps 1.0
import AppLayouts.Wallet.services.dapps.types 1.0
import shared.stores 1.0
import utils 1.0
import "types"
// Act as another layer of abstraction to the WalletConnectSDKBase
// Quick hack until the WalletConnectSDKBase could be refactored to a more generic DappProviderBase with API to match
// the UX requirements
WalletConnectSDKBase {
id: root
required property WalletConnectService wcService
required property var walletStore
required property DAppsStore store
required property int loginType
property var controller
property var dappInfo: null
property var txArgs: null
property bool sdkReady: true
property bool active: true
property string requestId: ""
property alias requestsModel: requests
readonly property string invalidDAppUrlError: "Invalid dappInfo: URL is missing"
readonly property string invalidDAppTopicError: "Invalid dappInfo: failed to parse topic"
projectId: ""
implicitWidth: 1
implicitHeight: 1
// TODO Refactor this code to avoid code duplication from Wallet Connect DAppsRequestHandler
// https://github.com/status-im/status-desktop/issues/15711
QtObject {
id: d
function sessionRequestEvent(event) {
let obj = d.resolveAsync(event)
if (obj === null) {
let error = true
controller.rejectTransactionSigning(root.requestId)
return
}
sessionRequestLoader.request = obj
requests.enqueue(obj)
}
function resolveAsync(event) {
let method = event.params.request.method
let accountAddress = lookupAccountFromEvent(event, method)
if(!accountAddress) {
console.error("Error finding accountAddress for event", JSON.stringify(event))
return null
}
let chainId = lookupNetworkFromEvent(event, method)
if(!chainId) {
console.error("Error finding network for event", JSON.stringify(event))
return null
}
let data = extractMethodData(event, method)
if(!data) {
console.error("Error in event data lookup", JSON.stringify(event))
return null
}
const interpreted = d.prepareData(method, data)
let enoughFunds = !isTransactionMethod(method)
let obj = sessionRequestComponent.createObject(null, {
event,
topic: event.topic,
id: event.id,
method,
accountAddress,
chainId,
data,
preparedData: interpreted.preparedData,
maxFeesText: "?",
maxFeesEthText: "?",
enoughFunds: enoughFunds
})
if (obj === null) {
console.error("Error creating SessionRequestResolved for event")
return null
}
// Check later to have a valid request object
if (!SessionRequest.getSupportedMethods().includes(method)) {
console.error("Unsupported method", method)
return null
}
let session = getActiveSession(root.dappInfo)
if (session === null) {
console.error("Connector.lookupSession: error finding session for requestId ", obj.requestId)
return
}
obj.resolveDappInfoFromSession(session)
if (d.isTransactionMethod(method)) {
let tx = obj.data.tx
if (tx === null) {
console.error("Error cannot resolve tx object")
return null
}
let BigOps = SQUtils.AmountsArithmetic
let gasLimit = hexToGwei(tx.gasLimit)
if (tx.gasPrice === null || tx.gasPrice === undefined) {
let maxFeePerGas = hexToGwei(tx.maxFeePerGas)
let maxPriorityFeePerGas = hexToGwei(tx.maxPriorityFeePerGas)
let totalMaxFees = BigOps.sum(maxFeePerGas, maxPriorityFeePerGas)
let maxFees = BigOps.times(gasLimit, totalMaxFees)
let maxFeesString = maxFees.toString()
obj.maxFeesText = maxFeesString
obj.maxFeesEthText = maxFeesString
obj.enoughFunds = true
} else {
let gasPrice = hexToGwei(tx.gasPrice)
let maxFees = BigOps.times(gasLimit, gasPrice)
let maxFeesString = maxFees.toString()
obj.maxFeesText = maxFeesString
obj.maxFeesEthText = maxFeesString
obj.enoughFunds = true
}
}
return obj
}
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) {
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)
if (tx.value) {
value = hexToEth(tx.value)
tx.value = value.toString()
}
if (tx.maxFeePerGas) {
tx.maxFeePerGas = hexToGwei(tx.maxFeePerGas).toString()
}
if (tx.maxPriorityFeePerGas) {
tx.maxPriorityFeePerGas = hexToGwei(tx.maxPriorityFeePerGas).toString()
}
if (tx.gasPrice) {
tx.gasPrice = hexToGwei(tx.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 isTransactionMethod(method) {
return method === SessionRequest.methods.signTransaction.name
|| method === SessionRequest.methods.sendTransaction.name
}
/// Returns null if the account is not found
function lookupAccountFromEvent(event, method) {
var address = ""
if (method === SessionRequest.methods.personalSign.name) {
if (event.params.request.params.length < 2) {
return address
}
address = event.params.request.params[1]
} else if (method === SessionRequest.methods.sign.name) {
if (event.params.request.params.length === 1) {
return address
}
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 address
}
address = event.params.request.params[0]
} else if (method === SessionRequest.methods.signTransaction.name
|| method === SessionRequest.methods.sendTransaction.name) {
if (event.params.request.params.length == 0) {
return address
}
address = event.params.request.params[0].from
} else {
console.error("Unsupported method to lookup account: ", method)
return null
}
const account = SQUtils.ModelUtils.getFirstModelEntryIf(root.wcService.validAccounts, (account) => {
return account.address.toLowerCase() === address.toLowerCase();
})
if (!account) {
return address
}
return account.address
}
/// 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.walletStore.filteredFlatModel, "chainId", chainId)
if (!network) {
return null
}
return network.chainId
}
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.tx.data
// 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
}
let jsonMessage = event.params.request.params[1]
let 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
}
let 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 executeSessionRequest(request, password, pin) {
if (!SessionRequest.getSupportedMethods().includes(request.method)) {
console.error("Unsupported method to execute: ", request.method)
return
}
if (password === "") {
console.error("No password provided to sign message")
return
}
if (request.method === SessionRequest.methods.sign.name) {
store.signMessageUnsafe(request.topic,
request.id,
request.accountAddress,
SessionRequest.methods.personalSign.getMessageFromData(request.data),
password,
pin)
} else if (request.method === SessionRequest.methods.personalSign.name) {
store.signMessage(request.topic,
request.id,
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.id,
request.accountAddress,
SessionRequest.methods.signTypedData.getMessageFromData(request.data),
request.chainId,
legacy,
password,
pin)
} else if (request.method === SessionRequest.methods.signTransaction.name) {
let txObj = SessionRequest.methods.signTransaction.getTxObjFromData(request.data)
store.signTransaction(request.topic,
request.id,
request.accountAddress,
request.chainId,
txObj,
password,
pin)
} else if (request.method === SessionRequest.methods.sendTransaction.name) {
store.sendTransaction(request.topic,
request.id,
request.account.address,
request.network.chainId,
request.data.tx,
password,
pin)
}
}
function acceptSessionRequest(topic, id, signature) {
console.debug(`Connector DappsConnectorSDK.acceptSessionRequest; requestId: ${root.requestId}, signature: "${signature}"`)
sessionRequestLoader.active = false
controller.approveTransactionRequest(requestId, signature)
root.wcService.displayToastMessage(qsTr("Successfully signed transaction from %1").arg(root.dappInfo.url), false)
}
function getActiveSession(dappInfos) {
let sessionTemplate = (dappUrl, dappName, dappIcon) => {
return {
"peer": {
"metadata": {
"description": "-",
"icons": [
dappIcon
],
"name": dappName,
"url": dappUrl
}
},
"topic": dappUrl
};
}
let session = root.wcService.connectorDAppsProvider.getActiveSession(dappInfos.url)
if (!session) {
console.error("Connector.lookupSession: error finding session for requestId ", root.requestId)
return
}
return sessionTemplate(session.url, session.name, session.icon)
}
function authenticate(request) {
return store.authenticateUser(request.topic, request.id, request.account.address)
}
}
Connections {
target: root.store
function onUserAuthenticated(topic, id, password, pin) {
var request = requests.findRequest(topic, id)
if (request === null) {
console.error(">Error finding event for topic", topic, "id", id)
return
}
d.executeSessionRequest(request, password, pin)
}
function onUserAuthenticationFailed(topic, id) {
var request = requests.findRequest(topic, id)
let methodStr = SessionRequest.methodToUserString(request.method)
if (request === null || !methodStr) {
return
}
d.lookupSession(topic, function(session) {
if (session === null)
return
root.displayToastMessage(qsTr("Failed to authenticate %1").arg(session.peer.metadata.url), true)
})
}
function onSigningResult(topic, id, data) {
let isSuccessful = (data != "")
if (isSuccessful) {
// acceptSessionRequest will trigger an sdk.sessionRequestUserAnswerResult signal
d.acceptSessionRequest(topic, id, data)
} else {
console.error("signing error")
}
}
}
Loader {
id: connectDappLoader
active: false
property var dappChains: []
property var sessionProposal: null
property var availableNamespaces: null
property var sessionTopic: null
readonly property var proposalMedatada: !!sessionProposal
? sessionProposal.params.proposer.metadata
: { name: "", url: "", icons: [] }
sourceComponent: ConnectDAppModal {
visible: true
onClosed: {
rejectSession(root.requestId)
connectDappLoader.active = false
}
flatNetworks: root.walletStore.filteredFlatModel
accounts: root.wcService.validAccounts
dAppUrl: proposalMedatada.url
dAppName: proposalMedatada.name
dAppIconUrl: !!proposalMedatada.icons && proposalMedatada.icons.length > 0 ? proposalMedatada.icons[0] : ""
multipleChainSelection: false
onConnect: {
connectDappLoader.active = false
approveSession(root.requestId, selectedAccount.address, selectedChains)
}
onDecline: {
connectDappLoader.active = false
rejectSession(root.requestId)
}
onDisconnect: {
connectDappLoader.active = false;
controller.recallDAppPermission(root.dappInfo.url)
}
}
}
Loader {
id: sessionRequestLoader
active: false
onLoaded: item.open()
property SessionRequestResolved request: null
property var dappInfo: null
sourceComponent: DAppSignRequestModal {
id: dappRequestModal
objectName: "connectorDappsRequestModal"
readonly property var account: accountEntry.available ? accountEntry.model : {
"address": "",
"name": "",
"emoji": "",
"colorId": 0
}
readonly property var network: networkEntry.available ? networkEntry.model : {
"chainId": 0,
"chainName": "",
"iconUrl": ""
}
loginType: account.migragedToKeycard ? Constants.LoginType.Keycard : root.loginType
formatBigNumber: (number, symbol, noSymbolOption) => root.wcService.walletRootStore.currencyStore.formatBigNumber(number, symbol, noSymbolOption)
visible: true
dappName: request.dappName
dappUrl: request.dappUrl
dappIcon: request.dappIcon
accountColor: Utils.getColorForId(account.colorId)
accountName: account.name
accountAddress: account.address
accountEmoji: account.emoji
networkName: network.chainName
networkIconPath: Style.svg(network.iconUrl)
fiatFees: request.maxFeesText
cryptoFees: request.maxFeesEthText
estimatedTime: ""
feesLoading: !request.maxFeesText || !request.maxFeesEthText
hasFees: signingTransaction
enoughFundsForTransaction: request.enoughFunds
enoughFundsForFees: request.enoughFunds
signingTransaction: request.method === SessionRequest.methods.signTransaction.name || request.method === SessionRequest.methods.sendTransaction.name
requestPayload: {
switch(request.method) {
case SessionRequest.methods.personalSign.name:
return SessionRequest.methods.personalSign.getMessageFromData(request.data)
case SessionRequest.methods.sign.name: {
return SessionRequest.methods.sign.getMessageFromData(request.data)
}
case SessionRequest.methods.signTypedData_v4.name: {
const stringPayload = SessionRequest.methods.signTypedData_v4.getMessageFromData(request.data)
return JSON.stringify(JSON.parse(stringPayload), null, 2)
}
case SessionRequest.methods.signTypedData.name: {
const stringPayload = SessionRequest.methods.signTypedData.getMessageFromData(root.payloadData)
return JSON.stringify(JSON.parse(stringPayload), null, 2)
}
case SessionRequest.methods.signTransaction.name: {
const jsonPayload = SessionRequest.methods.signTransaction.getTxObjFromData(request.data)
return JSON.stringify(jsonPayload, null, 2)
}
case SessionRequest.methods.sendTransaction.name: {
const jsonPayload = SessionRequest.methods.sendTransaction.getTxObjFromData(request.data)
return JSON.stringify(request.data, null, 2)
}
}
}
onClosed: {
Qt.callLater( () => {
sessionRequestLoader.active = false
})
}
onAccepted: {
if (!request) {
console.error("Error signing: request is null")
return
}
d.authenticate(request)
}
onRejected: {
sessionRequestLoader.active = false
controller.rejectTransactionSigning(root.requestId)
root.wcService.displayToastMessage(qsTr("Failed to sign transaction from %1").arg(request.dappUrl), true)
}
ModelEntry {
id: networkEntry
sourceModel: root.wcService.flatNetworks
key: "chainId"
value: request.chainId
}
ModelEntry {
id: accountEntry
sourceModel: root.wcService.validAccounts
key: "address"
value: request.accountAddress
}
}
}
Component {
id: sessionRequestComponent
SessionRequestResolved {
sourceId: Constants.DAppConnectors.StatusConnect
}
}
SessionRequestsModel {
id: requests
}
Connections {
target: root.wcService
function onRevokeSession(topic) {
if (!topic) {
console.warn(invalidDAppTopicError)
return
}
controller.recallDAppPermission(topic)
root.wcService.connectorDAppsProvider.revokeSession(topic)
}
}
Connections {
target: controller
onDappValidatesTransaction: function(requestId, dappInfoString) {
var dappInfo = JSON.parse(dappInfoString)
root.dappInfo = dappInfo
var txArgsParams = JSON.parse(dappInfo.txArgs)
root.txArgs = txArgsParams
let event = {
"id": root.requestId,
"topic": dappInfo.url,
"params": {
"chainId": `eip155:${dappInfo.chainId}`,
"request": {
"method": SessionRequest.methods.sendTransaction.name,
"params": [
{
"from": txArgsParams.from,
"to": txArgsParams.to,
"value": txArgsParams.value,
"gasLimit": txArgsParams.gas,
"gasPrice": txArgsParams.gasPrice,
"maxFeePerGas": txArgsParams.maxFeePerGas,
"maxPriorityFeePerGas": txArgsParams.maxPriorityFeePerGas,
"nonce": txArgsParams.nonce,
"data": txArgsParams.data
}
]
}
}
}
d.sessionRequestEvent(event)
sessionRequestLoader.active = true
root.requestId = requestId
}
onDappRequestsToConnect: function(requestId, dappInfoString) {
var dappInfo = JSON.parse(dappInfoString)
root.dappInfo = dappInfo
let sessionProposal = {
"params": {
"optionalNamespaces": {},
"proposer": {
"metadata": {
"description": "-",
"icons": [
dappInfo.icon
],
"name": dappInfo.name,
"url": dappInfo.url
}
},
"requiredNamespaces": {
"eip155": {
"chains": [
`eip155:${dappInfo.chainId}`
],
"events": [],
"methods": [SessionRequest.methods.personalSign.name]
}
}
}
};
connectDappLoader.sessionProposal = sessionProposal
connectDappLoader.active = true
root.requestId = requestId
}
onDappGrantDAppPermission: function(dappInfoString) {
let dappItem = JSON.parse(dappInfoString)
const { url, name, icon: iconUrl } = dappItem
if (!url) {
console.warn(invalidDAppUrlError)
return
}
root.wcService.connectorDAppsProvider.addSession(url, name, iconUrl)
}
onDappRevokeDAppPermission: function(dappInfoString) {
let dappItem = JSON.parse(dappInfoString)
let session = {
"url": dappItem.url,
"name": dappItem.name,
"iconUrl": dappItem.icon,
"topic": dappItem.url
}
if (!session.url) {
console.warn(invalidDAppUrlError)
return
}
root.wcService.connectorDAppsProvider.revokeSession(JSON.stringify(session))
root.wcService.displayToastMessage(qsTr("Disconnected from %1").arg(dappItem.url), false)
}
}
approveSession: function(requestId, account, selectedChains) {
controller.approveDappConnectRequest(requestId, account, JSON.stringify(selectedChains))
const { url, name, icon: iconUrl } = root.dappInfo;
//TODO: temporary solution until we have a proper way to handle accounts
//The dappProvider should add a new session only when the backend has validated the connection
//Currently the dapp info is limited to the url, name and icon
root.wcService.connectorDAppsProvider.addSession(url, name, iconUrl, account)
root.wcService.displayToastMessage(qsTr("Successfully authenticated %1").arg(url), false);
}
rejectSession: function(requestId) {
controller.rejectDappConnectRequest(requestId)
root.wcService.displayToastMessage(qsTr("Failed to authenticate %1").arg(root.dappInfo.url), true)
}
// We don't expect requests for these. They are here only to spot errors
pair: function(pairLink) { console.error("ConnectorSDK.pair: not implemented") }
getPairings: function(callback) { console.error("ConnectorSDK.getPairings: not implemented") }
disconnectPairing: function(topic) { console.error("ConnectorSDK.disconnectPairing: not implemented") }
buildApprovedNamespaces: function(params, supportedNamespaces) { console.error("ConnectorSDK.buildApprovedNamespaces: not implemented") }
}