From 7a8ecfc3a52b0d91587d1d6e7ebbf56579a50aed Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 23 Jun 2024 11:27:29 +0300 Subject: [PATCH] feat(dapps) implement eth_sendTransaction support for wallet Add `Fees` section to request modal Closes: #15126 --- .../wallet_connect/controller.nim | 3 + .../service/wallet_connect/service.nim | 37 +++++++++ storybook/pages/DAppRequestModalPage.qml | 27 +++++- storybook/pages/DAppsWorkflowPage.qml | 5 ++ .../Wallet/panels/DAppsWorkflow.qml | 10 ++- .../services/dapps/DAppsRequestHandler.qml | 42 ++++++---- .../services/dapps/types/SessionRequest.qml | 9 +- .../dapps/types/SessionRequestResolved.qml | 2 + .../popups/walletconnect/DAppRequestModal.qml | 82 ++++++++++++++++++- ui/imports/shared/stores/DAppsStore.qml | 17 +++- 10 files changed, 205 insertions(+), 29 deletions(-) diff --git a/src/app/modules/shared_modules/wallet_connect/controller.nim b/src/app/modules/shared_modules/wallet_connect/controller.nim index e552785b72..36a46345da 100644 --- a/src/app/modules/shared_modules/wallet_connect/controller.nim +++ b/src/app/modules/shared_modules/wallet_connect/controller.nim @@ -58,3 +58,6 @@ QtObject: proc signTransaction*(self: Controller, address: string, chainId: int, password: string, txJson: string): string {.slot.} = return self.service.signTransaction(address, chainId, password, txJson) + + proc sendTransaction*(self: Controller, address: string, chainId: int, password: string, txJson: string): string {.slot.} = + return self.service.sendTransaction(address, chainId, password, txJson) diff --git a/src/app_service/service/wallet_connect/service.nim b/src/app_service/service/wallet_connect/service.nim index d3277f0958..da1f83140a 100644 --- a/src/app_service/service/wallet_connect/service.nim +++ b/src/app_service/service/wallet_connect/service.nim @@ -5,6 +5,7 @@ import backend/wallet import app_service/service/settings/service as settings_service import app_service/common/wallet_constants +from app_service/service/transaction/dto import PendingTransactionTypeDto import app/global/global_singleton @@ -129,3 +130,39 @@ QtObject: return "" return txResponse["rawTx"].getStr + + proc sendTransaction*(self: Service, address: string, chainId: int, password: string, txJson: string): string = + var buildTxResponse: JsonNode + var err = wallet.buildTransaction(buildTxResponse, chainId, txJson) + if err.len > 0: + error "status-go - wallet_buildTransaction failed", err=err + return "" + if buildTxResponse.isNil or buildTxResponse.kind != JsonNodeKind.JObject or + not buildTxResponse.hasKey("txArgs") or not buildTxResponse.hasKey("messageToSign"): + error "unexpected wallet_buildTransaction response" + return "" + var txToBeSigned = buildTxResponse["messageToSign"].getStr + if txToBeSigned.len != wallet_constants.TX_HASH_LEN_WITH_PREFIX: + error "unexpected tx hash length" + return "" + + var signMsgRes: JsonNode + err = wallet.signMessage(signMsgRes, + txToBeSigned, + address, + hashPassword(password)) + if err.len > 0: + error "status-go - wallet_signMessage failed", err=err + let signature = singletonInstance.utils.removeHexPrefix(signMsgRes.getStr) + + var txResponse: JsonNode + err = wallet.sendTransactionWithSignature(txResponse, chainId, + $PendingTransactionTypeDto.WalletConnectTransfer, $buildTxResponse["txArgs"], signature) + if err.len > 0: + error "status-go - sendTransactionWithSignature failed", err=err + return "" + if txResponse.isNil or txResponse.kind != JsonNodeKind.JString: + error "unexpected sendTransactionWithSignature response" + return "" + + return txResponse.getStr diff --git a/storybook/pages/DAppRequestModalPage.qml b/storybook/pages/DAppRequestModalPage.qml index daa2d77487..d32bb6259e 100644 --- a/storybook/pages/DAppRequestModalPage.qml +++ b/storybook/pages/DAppRequestModalPage.qml @@ -20,6 +20,7 @@ import shared.popups.walletconnect 1.0 import SortFilterProxyModel 0.2 import AppLayouts.Wallet.panels 1.0 +import AppLayouts.Wallet.services.dapps.types 1.0 import utils 1.0 import shared.stores 1.0 @@ -53,6 +54,8 @@ Item { payloadData: d.currentPayload ? d.currentPayload.payloadData : null method: d.currentPayload ? d.currentPayload.method : "" maxFeesText: d.currentPayload ? d.currentPayload.maxFeesText : "" + maxFeesEthText: d.currentPayload ? d.currentPayload.maxFeesEthText : "" + enoughFunds: settings.enoughFunds estimatedTimeText: d.currentPayload ? d.currentPayload.estimatedTimeText : "" account: d.selectedAccount @@ -119,6 +122,13 @@ Item { d.currentPayload = d.payloadOptions[currentIndex] } } + StatusCheckBox { + id: enoughFundsCheckBox + + text: "Enough funds" + checked: settings.enoughFunds + onCheckedChanged: settings.enoughFunds = checked + } Item { Layout.fillHeight: true } } @@ -132,6 +142,7 @@ Item { property string dappIcon: "https://opensea.io/static/images/logos/opensea-logo.svg" property string accountDisplay: "helloworld" property int payloadMethod: 0 + property bool enoughFunds: true } QtObject { @@ -152,21 +163,31 @@ Item { readonly property var payloadOptions: [ { payloadData: {"message":"This is a message to sign.\nSigning this will prove ownership of the account."}, - method: "personal_sign", + method: SessionRequest.methods.personalSign.name, maxFeesText: "", + maxFeesEthText: "", estimatedTimeText: "" }, { payloadData: {"message": "{\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Person\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"wallet\",\"type\":\"address\"}],\"Mail\":[{\"name\":\"from\",\"type\":\"Person\"},{\"name\":\"to\",\"type\":\"Person\"},{\"name\":\"contents\",\"type\":\"string\"}]},\"primaryType\":\"Mail\",\"domain\":{\"name\":\"Ether Mail\",\"version\":\"1\",\"chainId\":1,\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\"},\"message\":{\"from\":{\"name\":\"Cow\",\"wallet\":\"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\"},\"to\":{\"name\":\"Bob\",\"wallet\":\"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\"},\"contents\":\"Hello, Bob!\"}}"}, - method: "eth_signTypedData_v4", + method: SessionRequest.methods.signTypedData_v4.name, maxFeesText: "", + maxFeesEthText: "", estimatedTimeText: "" }, { payloadData: {"tx":{"data":"0x","from":"0xE2d622C817878dA5143bBE06866ca8E35273Ba8a","gasLimit":"0x5208","gasPrice":"0x048ddbc5","nonce":"0x2a","to":"0xE2d622C817878dA5143bBE06866ca8E35273Ba8a","value":"0x00"}}, - method: "eth_signTransaction", + method: SessionRequest.methods.signTransaction.name, maxFeesText: "1.82 EUR", + maxFeesEthText: "0.0001 ETH", estimatedTimeText: "3-5 mins" + }, + { + payloadData: {"tx":{"data":"0x","from":"0xE2d622C817878dA5143bBE06866ca8E35273Ba8a","gasLimit":"0x5208","gasPrice":"0x048ddbc5","nonce":"0x2a","to":"0xE2d622C817878dA5143bBE06866ca8E35273Ba8a","value":"0x00"}}, + method: SessionRequest.methods.sendTransaction.name, + maxFeesText: "0.92 EUR", + maxFeesEthText: "0.00005 ETH", + estimatedTimeText: "1-2 mins" } ] } diff --git a/storybook/pages/DAppsWorkflowPage.qml b/storybook/pages/DAppsWorkflowPage.qml index 7f6b259d3b..b358f24657 100644 --- a/storybook/pages/DAppsWorkflowPage.qml +++ b/storybook/pages/DAppsWorkflowPage.qml @@ -299,6 +299,11 @@ Item { console.info(`calling mocked DAppsStore.signTransaction(${topic}, ${id}, ${address}, ${chainId}, ${password}, ${tx})`) return "0xf8672a8402fb7acf82520894e2d622c817878da5143bbe06866ca8e35273ba8a80808401546d71a04fc89c2f007c3b27d0fcff07d3e69c29f940967fab4caf525f9af72dadb48befa00c5312a3cb6f50328889ad361a0c88bb9d1b1a4fc510f6783b287930b4e187b5" } + + function sendTransaction(topic, id, address, chainId, password, tx) { + console.info(`calling mocked DAppsStore.sendTransaction(${topic}, ${id}, ${address}, ${chainId}, ${password}, ${tx})`) + return "0xf8672a8402fb7acf82520894e2d622c817878da5143bbe068" + } } walletStore: WalletStore { diff --git a/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml b/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml index e5aa2373eb..478c9cbac7 100644 --- a/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml +++ b/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml @@ -130,6 +130,8 @@ ConnectedDappsButton { payloadData: request.data method: request.method maxFeesText: request.maxFeesText + maxFeesEthText: request.maxFeesEthText + enoughFunds: request.enoughFunds estimatedTimeText: request.estimatedTimeText visible: true @@ -153,8 +155,14 @@ ConnectedDappsButton { Connections { target: root.wcService.requestHandler - function onMaxFeesUpdated(maxFees, symbol) { + function onMaxFeesUpdated(maxFees, maxFeesWei, haveEnoughFunds, symbol) { maxFeesText = `${maxFees.toFixed(2)} ${symbol}` + var ethStr = "?" + if (globalUtils) { + ethStr = globalUtils.wei2Eth(maxFeesWei, 9) + } + maxFeesEthText = `${ethStr} ETH` + enoughFunds = haveEnoughFunds } function onEstimatedTimeUpdated(minMinutes, maxMinutes) { estimatedTimeText = qsTr("%1-%2mins").arg(minMinutes).arg(maxMinutes) diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml index 930cbc0f40..80d27467db 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml @@ -32,7 +32,7 @@ QObject { signal sessionRequest(SessionRequestResolved request) signal displayToastMessage(string message, bool error) signal sessionRequestResult(/*model entry of SessionRequestResolved*/ var request, bool isSuccess) - signal maxFeesUpdated(real maxFees, string symbol) + signal maxFeesUpdated(real maxFees, int maxFeesWei, bool haveEnoughFunds, string symbol) signal estimatedTimeUpdated(int minMinutes, int maxMinutes) Connections { @@ -134,8 +134,10 @@ QObject { account, network, data, - maxFeesText: "-", - estimatedTimeText: "-" + maxFeesText: "?", + maxFeesEthText: "?", + enoughFunds: false, + estimatedTimeText: "?" }) if (obj === null) { console.error("Error creating SessionRequestResolved for event") @@ -143,9 +145,7 @@ QObject { } // Check later to have a valid request object - if (!SessionRequest.getSupportedMethods().includes(method) - // TODO #14927: support method eth_sendTransaction - || method == SessionRequest.methods.sendTransaction.name) { + if (!SessionRequest.getSupportedMethods().includes(method)) { console.error("Unsupported method", method) return null } @@ -160,9 +160,10 @@ QObject { // TODO #15192: update maxFees let gasLimit = parseFloat(parseInt(event.params.request.params[0].gasLimit, 16)); let gasPrice = parseFloat(parseInt(event.params.request.params[0].gasPrice, 16)); - root.maxFeesUpdated((gasLimit * gasPrice)/1000000000, "Gwei") + let maxFees = gasLimit * gasPrice + root.maxFeesUpdated(maxFees/1000000000, maxFees, true, "Gwei") // TODO #15192: update estimatedTime - root.estimatedTimeUpdated(3, 12) + root.estimatedTimeUpdated(1, 12) }) return obj @@ -181,7 +182,8 @@ QObject { return null } address = event.params.request.params[0] - } else if (method === SessionRequest.methods.signTransaction.name) { + } else if (method === SessionRequest.methods.signTransaction.name + || method === SessionRequest.methods.sendTransaction.name) { if (event.params.request.params.length == 0) { return null } @@ -233,6 +235,12 @@ QObject { } 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 + } + let tx = event.params.request.params[0] + return SessionRequest.methods.sendTransaction.buildDataObject(tx) } else { return null } @@ -256,26 +264,30 @@ QObject { } if (password !== "") { - var signedMessage = "" + var actionResult = "" if (request.method === SessionRequest.methods.personalSign.name) { // TODO #14756: clarify why prefixing the message fails the test app https://react-app.walletconnect.com/ //let finalMessage = "\x19Ethereum Signed Message:\n" + originalMessage.length + originalMessage - signedMessage = store.signMessage(request.topic, request.id, + actionResult = store.signMessage(request.topic, request.id, request.account.address, password, SessionRequest.methods.personalSign.getMessageFromData(request.data)) } else if (request.method === SessionRequest.methods.signTypedData_v4.name) { - signedMessage = store.signTypedDataV4(request.topic, request.id, + actionResult = store.signTypedDataV4(request.topic, request.id, request.account.address, password, SessionRequest.methods.signTypedData_v4.getMessageFromData(request.data)) } else if (request.method === SessionRequest.methods.signTransaction.name) { let txObj = SessionRequest.methods.signTransaction.getTxObjFromData(request.data) - signedMessage = store.signTransaction(request.topic, request.id, + actionResult = store.signTransaction(request.topic, request.id, + request.account.address, request.network.chainId, password, txObj) + } else if (request.method === SessionRequest.methods.sendTransaction.name) { + let txObj = SessionRequest.methods.sendTransaction.getTxObjFromData(request.data) + actionResult = store.sendTransaction(request.topic, request.id, request.account.address, request.network.chainId, password, txObj) } - let isSuccessful = (signedMessage != "") + let isSuccessful = (actionResult != "") if (isSuccessful) { // acceptSessionRequest will trigger an sdk.sessionRequestUserAnswerResult signal - sdk.acceptSessionRequest(request.topic, request.id, signedMessage) + sdk.acceptSessionRequest(request.topic, request.id, actionResult) } else { root.sessionRequestResult(request, isSuccessful) } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml index 892356f85d..fd7496aff7 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml @@ -6,6 +6,8 @@ import utils 1.0 QtObject { /// Supported methods + /// userString is used in the context `dapp.url #{userString} ` + /// requestDisplay is used in the context `dApp wants you to ${requestDisplay} with ` property QtObject methods: QtObject { readonly property QtObject personalSign: QtObject { readonly property string name: Constants.personal_sign @@ -35,8 +37,11 @@ QtObject { readonly property QtObject sendTransaction: QtObject { readonly property string name: "eth_sendTransaction" - readonly property string userString: qsTr("send transaction") - //function buildDataObject(message) { return {/* TODO #15126 */}} + readonly property string userString: qsTr("transaction") + readonly property string requestDisplay: qsTr("sign this transaction") + + function buildDataObject(tx) { return {tx}} + function getTxObjFromData(data) { return data.tx } } readonly property var all: [personalSign, signTypedData_v4, signTransaction, sendTransaction] } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml index b443dd7626..03af1cee7e 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml @@ -29,6 +29,8 @@ QObject { readonly property alias dappIcon: d.dappIcon readonly property string maxFeesText: "" + readonly property string maxFeesEthText: "" + readonly property bool enoughFunds: false readonly property string estimatedTimeText: "" function resolveDappInfoFromSession(session) { diff --git a/ui/imports/shared/popups/walletconnect/DAppRequestModal.qml b/ui/imports/shared/popups/walletconnect/DAppRequestModal.qml index ecc607f3a8..4a6acfed1d 100644 --- a/ui/imports/shared/popups/walletconnect/DAppRequestModal.qml +++ b/ui/imports/shared/popups/walletconnect/DAppRequestModal.qml @@ -25,8 +25,10 @@ StatusDialog { required property url dappIcon required property string method required property var payloadData - required property string maxFeesText - required property string estimatedTimeText + property string maxFeesText: "" + property string maxFeesEthText: "" + property bool enoughFunds: false + property string estimatedTimeText: "" required property var account property var network: null @@ -126,6 +128,13 @@ StatusDialog { Item {Layout.fillWidth: true } } } + + StatusBaseText { + text: qsTr("Network") + font.pixelSize: 13 + color: Theme.palette.directColor1 + } + // TODO #14762: implement proper control to display the chain Rectangle { Layout.fillWidth: true @@ -163,6 +172,62 @@ StatusDialog { Item {Layout.fillWidth: true } } } + + StatusBaseText { + text: qsTr("Fees") + font.pixelSize: 13 + color: Theme.palette.directColor1 + visible: d.isTransaction() + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 76 + + visible: root.network !== null && d.isTransaction() + + radius: 8 + border.width: 1 + border.color: Theme.palette.baseColor2 + color: "transparent" + + RowLayout { + spacing: 12 + anchors.fill: parent + anchors.margins: 16 + + StatusBaseText { + text: qsTr("Max. fees on %1").arg(!!root.network && root.network.chainName) + + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + + font.pixelSize: 13 + color: Theme.palette.baseColor1 + } + + Item {Layout.fillWidth: true } + + ColumnLayout { + StatusBaseText { + text: root.maxFeesText + + Layout.alignment: Qt.AlignRight + + font.pixelSize: 13 + color: root.enoughFunds ? Theme.palette.directColor1 : Theme.palette.dangerColor1 + } + + StatusBaseText { + text: root.maxFeesEthText + + Layout.alignment: Qt.AlignRight + + font.pixelSize: 13 + color: root.enoughFunds ? Theme.palette.baseColor1 : Theme.palette.dangerColor1 + } + } + } + } } } } @@ -248,7 +313,7 @@ StatusDialog { visible: !!root.maxFeesText font.pixelSize: 16 - font.weight: Font.DemiBold + color: root.enoughFunds ? Theme.palette.directColor1 : Theme.palette.dangerColor1 } StatusBaseText { text: qsTr("No fees") @@ -269,7 +334,6 @@ StatusDialog { StatusBaseText { text: root.estimatedTimeText font.pixelSize: 16 - font.weight: Font.DemiBold } } @@ -426,6 +490,10 @@ StatusDialog { property string payloadToDisplay: "" property string userDisplayNaming: "" + function isTransaction() { + return root.method === SessionRequest.methods.signTransaction.name || root.method === SessionRequest.methods.sendTransaction.name + } + function updateDisplay() { if (!root.payloadData) return @@ -448,6 +516,12 @@ StatusDialog { userDisplayNaming = SessionRequest.methods.signTransaction.requestDisplay break } + case SessionRequest.methods.sendTransaction.name: { + let tx = SessionRequest.methods.sendTransaction.getTxObjFromData(root.payloadData) + payloadToDisplay = JSON.stringify(tx, null, 2) + userDisplayNaming = SessionRequest.methods.sendTransaction.requestDisplay + break + } } } } diff --git a/ui/imports/shared/stores/DAppsStore.qml b/ui/imports/shared/stores/DAppsStore.qml index 97e68c2971..6a7372db9d 100644 --- a/ui/imports/shared/stores/DAppsStore.qml +++ b/ui/imports/shared/stores/DAppsStore.qml @@ -39,10 +39,9 @@ QObject { return fixed == '0x' ? '0x0' : fixed; } - // Returns the hex encoded signature of the transaction or empty string if error - function signTransaction(topic, id, address, chainId, password, txObj) { - // Strip leading zeros from numbers as expected by status-go - let tx = { + // Strip leading zeros from numbers as expected by status-go + function prepareTxForStatusGo(txObj) { + return { data: txObj.data, from: txObj.from, gasLimit: stripLeadingZeros(txObj.gasLimit), @@ -51,9 +50,19 @@ QObject { to: txObj.to, value: stripLeadingZeros(txObj.value) } + } + // Returns the hex encoded signature of the transaction or empty string if error + function signTransaction(topic, id, address, chainId, password, txObj) { + let tx = prepareTxForStatusGo(txObj) return controller.signTransaction(address, chainId, password, JSON.stringify(tx)) } + // Returns the hash of the transaction or empty string if error + function sendTransaction(topic, id, address, chainId, password, txObj) { + let tx = prepareTxForStatusGo(txObj) + return controller.sendTransaction(address, chainId, password, JSON.stringify(tx)) + } + /// \c getDapps triggers an async response to \c dappsListReceived function getDapps() { return controller.getDapps()