feat(dapps) implement signing of messages

Implement infrastructure and integration with status-go to support
general session requests

Supported methods:
- personal_sign
- eth_signTypedData_v4

depends on status-go change that exposes the signing methods

Also

- support hex or utf8 encoding for personal_sign
- format the typed data for display in the modal

Tests are disabled for now, as they are crashing on CI

Close: #14927
This commit is contained in:
Stefan 2024-06-07 19:54:19 +03:00 committed by Stefan Dunca
parent 28a7b691cd
commit 758dbc55e5
13 changed files with 198 additions and 92 deletions

View File

@ -38,7 +38,7 @@ QtObject:
self.dappsListReceived(res)
return true
proc userAuthenticationResult*(self: Controller, topic: string, id: string, error: bool) {.signal.}
proc userAuthenticationResult*(self: Controller, topic: string, id: string, error: bool, password: string, pin: string) {.signal.}
# Beware, it will fail if an authentication is already in progress
proc authenticateUser*(self: Controller, topic: string, id: string, address: string): bool {.slot.} =
@ -46,6 +46,12 @@ QtObject:
if acc.keyUid == "":
return false
return self.service.authenticateUser(acc.keyUid, proc(success: bool) =
self.userAuthenticationResult(topic, id, success)
)
return self.service.authenticateUser(acc.keyUid, proc(password: string, pin: string, success: bool) =
self.userAuthenticationResult(topic, id, success, password, pin)
)
proc signMessage*(self: Controller, address: string, password: string, message: string): string {.slot.} =
return self.service.signMessage(address, password, message)
proc signTypedDataV4*(self: Controller, address: string, password: string, typedDataJson: string): string {.slot.} =
return self.service.signTypedDataV4(address, password, typedDataJson)

View File

@ -20,7 +20,7 @@ logScope:
const UNIQUE_WALLET_CONNECT_MODULE_IDENTIFIER* = "WalletSection-WCModule"
type
AuthenticationResponseFn* = proc(success: bool)
AuthenticationResponseFn* = proc(password: string, pin: string, success: bool)
QtObject:
type Service* = ref object of QObject
@ -59,10 +59,10 @@ QtObject:
if args.password == "" and args.pin == "":
info "fail to authenticate user"
self.authenticationCallback(false)
self.authenticationCallback("", "", false)
return
self.authenticationCallback(true)
self.authenticationCallback(args.password, args.pin, true)
proc addSession*(self: Service, session_json: string): bool =
# TODO #14588: call it async
@ -85,4 +85,10 @@ QtObject:
keyUid: keyUid)
self.events.emit(SIGNAL_SHARED_KEYCARD_MODULE_AUTHENTICATE_USER, data)
return true
return true
proc signMessage*(self: Service, address: string, password: string, message: string): string =
return status_go.signMessage(address, password, message)
proc signTypedDataV4*(self: Service, address: string, password: string, typedDataJson: string): string =
return status_go.signTypedData(address, password, typedDataJson)

View File

@ -1,20 +1,32 @@
import options, logging
import json, json_serialization
import core, response_type
import strutils
from gen import rpc
import backend
import status_go
import app_service/service/community/dto/sign_params
import app_service/common/utils
rpc(addWalletConnectSession, "wallet"):
sessionJson: string
proc isErrorResponse(rpcResponse: RpcResponse[JsonNode]): bool =
return not rpcResponse.error.isNil
rpc(signTypedDataV4, "wallet"):
typedJson: string
address: string
password: string
proc isSuccessResponse(rpcResponse: RpcResponse[JsonNode]): bool =
return rpcResponse.error.isNil
proc addSession*(sessionJson: string): bool =
try:
let rpcRes = addWalletConnectSession(sessionJson)
return isErrorResponse(rpcRes):
return isSuccessResponse(rpcRes):
except Exception as e:
warn "AddWalletConnectSession failed: ", "msg", e.msg
return false
@ -33,3 +45,29 @@ proc getDapps*(validAtEpoch: int64, testChains: bool): string =
except Exception as e:
warn "GetWalletConnectDapps failed: ", "msg", e.msg
return ""
proc signMessage*(address: string, password: string, message: string): string =
try:
let signParams = SignParamsDto(address: address, password: hashPassword(password), data: "0x" & toHex(message))
let paramsStr = $toJson(signParams)
let rpcResRaw = status_go.signMessage(paramsStr)
let rpcRes = Json.decode(rpcResRaw, RpcResponse[JsonNode])
if(not rpcRes.error.isNil):
return ""
return rpcRes.result.getStr()
except Exception as e:
warn "status_go.signMessage failed: ", "msg", e.msg
return ""
proc signTypedData*(address: string, password: string, typedDataJson: string): string =
try:
let rpcRes = signTypedDataV4(typedDataJson, address, hashPassword(password))
if not isSuccessResponse(rpcRes):
return ""
return rpcRes.result.getStr()
except Exception as e:
warn "wallet_signTypedDataV4 failed: ", "msg", e.msg
return ""

View File

@ -50,7 +50,8 @@ Item {
dappName: settings.dappName
dappUrl: settings.dappUrl
dappIcon: settings.dappIcon
signContent: JSON.stringify(d.signTestContent, null, 2)
signContent: d.signTestContent
method: "eth_signTypedData_v4"
maxFeesText: "1.82 EUR"
estimatedTimeText: "3-5 mins"
@ -128,36 +129,7 @@ Item {
readonly property var selectedNetwork: NetworksModel.flatNetworks.get(0)
readonly property var signTestContent: {
"id": 1714038548266495,
"params": {
"chainld": "eip155:11155111",
"request": {
"expiryTimestamp": 1714038848,
"method": "eth_signTransaction",
"params": [
{
"data": "0x",
"from": "0xE2d622C817878dA5143bBE06866ca8E35273Ba8",
"gasLimit": "0x5208",
"gasPrice": "0xa677ef31",
"nonce": "0x27",
"to": "0xE2d622C817878dA5143bBE06866ca8E35273Ba8a",
"value": "0x00"
}
]
}
},
"topic": "a0f85b23a1f3a540d85760a523963165fb92169d57320c",
"verifyContext": {
"verified": {
"isScam": false,
"origin": "https://react-app.walletconnect.com/",
"validation": "VALID",
"verifyUrl": "https://verify.walletconnect.com/"
}
}
}
readonly property var signTestContent: "{\"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!\"}}"
}
}

View File

@ -241,7 +241,7 @@ Item {
StatusButton {
text: qsTr("Authenticate")
onClicked: {
walletConnectService.store.userAuthenticated(authMockDialog.topic, authMockDialog.id)
walletConnectService.store.userAuthenticated(authMockDialog.topic, authMockDialog.id, "0x1234567890", "123")
authMockDialog.close()
}
}
@ -260,9 +260,8 @@ Item {
store: DAppsStore {
signal dappsListReceived(string dappsJson)
signal userAuthenticated(string topic, string id)
signal userAuthenticated(string topic, string id, string password, string pin)
signal userAuthenticationFailed(string topic, string id)
signal sessionRequestExecuted(var payload, bool success)
function addWalletConnectSession(sessionJson) {
console.info("Persist Session", sessionJson)
@ -276,6 +275,7 @@ Item {
"iconUrl": firstIconUrl
}
d.persistedDapps.push(persistedDapp)
return true
}
function getDapps() {
@ -289,6 +289,16 @@ Item {
authMockDialog.open()
return true
}
// hardcoded for https://react-app.walletconnect.com/
function signMessage(topic, id, address, password, message) {
return "0x0b083acc1b3b612dd38e8e725b28ce9b2dd4936b4cf7922da4e4a3c6f44f7f4f6d3050ccb41455a2b85093f1bfadb10fc6a75d83bb590b2eb70e3447653459701c"
}
// hardcoded for https://react-app.walletconnect.com/
function signTypedDataV4(topic, id, address, password, typedDataJson) {
return "0xf8ceb3468319cc215523b67c24c4504b3addd9bf8de31c278038d7478c9b6de554f7d8a516cd5d6a066b7d48b81f03d9d6bb7d5d754513c08325674ebcc7efbc1b"
}
}
walletStore: WalletStore {

View File

@ -25,6 +25,7 @@ Item {
width: 600
height: 400
// TODO #15151 fix CI crash and re-enable tests
// Component {
// id: sdkComponent
@ -79,9 +80,8 @@ Item {
// DAppsStore {
// signal dappsListReceived(string dappsJson)
// signal userAuthenticated(string topic, string id)
// signal userAuthenticated(string topic, string id, string password, string pin)
// signal userAuthenticationFailed(string topic, string id)
// signal sessionRequestExecuted(var payload, bool success)
// // By default, return no dapps in store
// function getDapps() {
@ -100,8 +100,12 @@ Item {
// }
// property var signMessageCalls: []
// function signMessage(message) {
// signMessageCalls.push({message})
// function signMessage(topic, id, address, password, message) {
// signMessageCalls.push({topic, id, address, password, message})
// }
// property var signTypedDataV4Calls: []
// function signTypedDataV4(topic, id, address, password, message) {
// signTypedDataV4Calls.push({topic, id, address, password, message})
// }
// }
// }
@ -173,7 +177,7 @@ Item {
// compare(handler.store.authenticateUserCalls.length, 1, "expected a call to store.authenticateUser")
// let store = handler.store
// store.userAuthenticated(td.topic, td.request.id)
// store.userAuthenticated(td.topic, td.request.id, "password", "")
// compare(store.signMessageCalls.length, 1, "expected a call to store.signMessage")
// compare(store.signMessageCalls[0].message, td.request.data)
// }
@ -414,7 +418,7 @@ Item {
// }
// }
// // Beware this TestCase should be last; I had it before ServiceHelpers and it was not run with `when: windowShown`
// TODO #15151: this TestCase if placed before ServiceHelpers was not run with `when: windowShown`. Check if related to the CI crash
// TestCase {
// id: dappsWorkflowTest

View File

@ -129,6 +129,7 @@ ConnectedDappsButton {
dappIcon: request.dappIcon
signContent: request.data.message
method: request.method
maxFeesText: request.maxFeesText
estimatedTimeText: request.estimatedTimeText
@ -155,9 +156,12 @@ ConnectedDappsButton {
Connections {
target: root.wcService ? root.wcService.requestHandler : null
function onSessionRequestResult(payload, isSuccess) {
// TODO #14927 handle this properly
sessionRequestLoader.active = false
function onSessionRequestResult(request, payload, isSuccess) {
if (isSuccess) {
sessionRequestLoader.active = false
} else {
// TODO #14762 handle the error case
}
}
}

View File

@ -30,7 +30,7 @@ QObject {
signal sessionRequest(SessionRequestResolved request)
signal displayToastMessage(string message, bool error)
signal sessionRequestResult(var payload, bool isSuccess)
signal sessionRequestResult(/*model entry of SessionRequestResolved*/ var request, var payload, bool isSuccess)
/// Supported methods
property QtObject methods: QtObject {
@ -38,11 +38,16 @@ QObject {
readonly property string name: Constants.personal_sign
readonly property string userString: qsTr("sign")
}
readonly property QtObject signTypedData_v4: QtObject {
readonly property string name: "eth_signTypedData_v4"
readonly property string userString: qsTr("sign typed data")
}
readonly property QtObject sendTransaction: QtObject {
readonly property string name: "eth_sendTransaction"
readonly property string userString: qsTr("send transaction")
}
readonly property var all: [personalSign, sendTransaction]
readonly property var all: [personalSign, signTypedData_v4, sendTransaction]
}
function getSupportedMethods() {
@ -81,14 +86,17 @@ QObject {
return
if (error) {
root.displayToastMessage(qsTr("Fail to %1 from %2").arg(methodStr).arg(session.peer.metadata.url), true)
// TODO #14757 handle SDK error on user accept/reject
root.sessionRequestResult(request, "", false /*isSuccessful*/)
console.error(`Error accepting session request for topic: ${topic}, id: ${id}, accept: ${accept}, error: ${error}`)
return
}
let actionStr = accept ? qsTr("accepted") : qsTr("rejected")
root.displayToastMessage("%1 %2 %3".arg(session.peer.metadata.url).arg(methodStr).arg(actionStr), false)
root.sessionRequestApprovalResult()
root.sessionRequestResult(request, "", true /*isSuccessful*/)
})
}
}
@ -96,13 +104,13 @@ QObject {
Connections {
target: root.store
function onUserAuthenticated(topic, id) {
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)
d.executeSessionRequest(request, password, pin)
}
function onUserAuthenticationFailed(topic, id) {
@ -117,11 +125,6 @@ QObject {
root.displayToastMessage(qsTr("Failed to authenticate %1 from %2").arg(methodStr).arg(session.peer.metadata.url), true)
})
}
function onSessionRequestExecuted(payload, isSuccess) {
// TODO #14927 handle this properly
root.sessionRequestResult(payload, isSuccess)
}
}
QObject {
@ -168,24 +171,24 @@ QObject {
/// Returns null if the account is not found
function lookupAccountFromEvent(event, method) {
var address = ""
if (method === root.methods.personalSign.name) {
if (event.params.request.params.length < 2) {
return null
}
var address = event.params.request.params[1]
for (let i = 0; i < walletStore.ownAccounts.count; i++) {
let acc = ModelUtils.get(walletStore.ownAccounts, i)
if (acc.address === address) {
return acc
}
address = event.params.request.params[1]
} else if(method === root.methods.signTypedData_v4.name) {
if (event.params.request.params.length < 2) {
return null
}
address = event.params.request.params[0]
}
return null
return ModelUtils.getByKey(walletStore.ownAccounts, "address", address)
}
/// Returns null if the network is not found
function lookupNetworkFromEvent(event, method) {
if (method === root.methods.personalSign.name) {
if (method === root.methods.personalSign.name || method === root.methods.signTypedData_v4.name) {
let chainId = Helpers.chainIdFromEip155(event.params.chainId)
for (let i = 0; i < walletStore.flatNetworks.count; i++) {
let network = ModelUtils.get(walletStore.flatNetworks, i)
@ -202,9 +205,22 @@ QObject {
if (event.params.request.params.length == 0) {
return null
}
let hexMessage = event.params.request.params[0]
var message = ""
let messageParam = event.params.request.params[0]
// There is no standard on how data is encoded. Therefore we support hex or utf8
if (Helpers.isHex(messageParam)) {
message = Helpers.hexToString(messageParam)
} else {
message = messageParam
}
return {message}
} else if (method === root.methods.signTypedData_v4.name) {
if (event.params.request.params.length < 2) {
return null
}
let jsonMessage = event.params.request.params[1]
return {
message: Helpers.hexToString(hexMessage)
message: jsonMessage
}
}
}
@ -229,10 +245,33 @@ QObject {
})
}
function executeSessionRequest(request) {
if (request.method === root.methods.personalSign.name) {
store.signMessage(request.data.message)
console.debug("TODO #14927 sign message: ", request.data.message)
function executeSessionRequest(request, password, pin) {
if (request.method === root.methods.personalSign.name || request.method === root.methods.signTypedData_v4.name) {
if (password !== "") {
//let originalMessage = request.data.message
// 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
let finalMessage = request.data.message
var signedMessage = ""
if (request.method === root.methods.personalSign.name) {
signedMessage = store.signMessage(request.topic, request.id,
request.account.address, password, finalMessage)
} else if (request.method === root.methods.signTypedData_v4.name) {
signedMessage = store.signTypedDataV4(request.topic, request.id,
request.account.address, password, finalMessage)
}
let isSuccessful = signedMessage != ""
if (isSuccessful) {
// acceptSessionRequest will trigger an sdk.sessionRequestUserAnswerResult signal
sdk.acceptSessionRequest(request.topic, request.id, signedMessage)
} else {
root.sessionRequestResult(request, request.data.message, isSuccessful)
}
} else if (pin !== "") {
console.debug("TODO #14927 sign message using keycard: ", request.data.message)
} else {
console.error("No password or pin provided to sign message")
}
} else {
console.error("Unsupported method to execute: ", request.method)
}

View File

@ -127,7 +127,7 @@ WalletConnectSDKBase {
if (d.engine) {
d.engine.runJavaScript(`wc.getActiveSessions()`, function(result) {
let allSessions = ""
var allSessions = ""
for (var key of Object.keys(result)) {
allSessions += `\nsessionTopic: ${key} relatedPairingTopic: ${result[key].pairingTopic}`;
}

View File

@ -99,7 +99,9 @@ QObject {
root.displayToastMessage(qsTr("Connected to %1 via WalletConnect").arg(app_url), false)
// Persist session
store.addWalletConnectSession(JSON.stringify(session))
if(!store.addWalletConnectSession(JSON.stringify(session))) {
console.error("Failed to persist session")
}
// Notify client
root.approveSessionResult(session, err)

View File

@ -4,6 +4,10 @@ function chainIdFromEip155(chain) {
return parseInt(chain.split(':').pop().trim(), 10)
}
function isHex(str) {
return str.startsWith('0x') && str.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(str.slice(2))
}
function hexToString(hex) {
if (hex.startsWith("0x")) {
hex = hex.substring(2);

View File

@ -21,6 +21,7 @@ StatusDialog {
required property string dappName
required property string dappUrl
required property url dappIcon
required property string method
required property string signContent
required property string maxFeesText
required property string estimatedTimeText
@ -35,6 +36,10 @@ StatusDialog {
padding: 20
onSignContentChanged: d.updatePayloadToDisplay()
onMethodChanged: d.updatePayloadToDisplay()
Component.onCompleted: d.updatePayloadToDisplay()
contentItem: StatusScrollView {
id: scrollView
padding: 0
@ -50,7 +55,6 @@ StatusDialog {
dappName: root.dappName
dappIcon: root.dappIcon
account: root.account
signContent: root.signContent
}
ContentPanel {
@ -273,7 +277,6 @@ StatusDialog {
required property string dappName
required property url dappIcon
required property var account
required property string signContent
// Icons
Item {
@ -405,10 +408,24 @@ StatusDialog {
width: contentScrollView.availableWidth
text: signContent
text: d.payloadToDisplay
wrapMode: Text.WrapAnywhere
}
}
}
QtObject {
id: d
property string payloadToDisplay: ""
function updatePayloadToDisplay() {
if (root.method === "eth_signTypedData_v4" && root.signContent) {
payloadToDisplay = JSON.stringify(JSON.parse(root.signContent), null, 2)
return
}
payloadToDisplay = root.signContent
}
}
}

View File

@ -9,12 +9,11 @@ QObject {
/// \c dappsJson serialized from status-go.wallet.GetDapps
signal dappsListReceived(string dappsJson)
signal userAuthenticated(string topic, string id)
signal userAuthenticated(string topic, string id, string password, string pin)
signal userAuthenticationFailed(string topic, string id)
signal sessionRequestExecuted(var payload, bool success)
function addWalletConnectSession(sessionJson) {
controller.addWalletConnectSession(sessionJson)
return controller.addWalletConnectSession(sessionJson)
}
function authenticateUser(topic, id, address) {
@ -24,9 +23,14 @@ QObject {
}
}
function signMessage(message) {
// TODO #14927 implement me
root.sessionRequestExecuted(message, true)
// Returns the hex encoded signature of the message or empty string if error
function signMessage(topic, id, address, password, message) {
return controller.signMessage(address, password, message)
}
// Returns the hex encoded signature of the typedDataJson or empty string if error
function signTypedDataV4(topic, id, address, password, typedDataJson) {
return controller.signTypedDataV4(address, password, typedDataJson)
}
/// \c getDapps triggers an async response to \c dappsListReceived
@ -42,9 +46,9 @@ QObject {
root.dappsListReceived(dappsJson)
}
function onUserAuthenticationResult(topic, id, success) {
function onUserAuthenticationResult(topic, id, success, password, pin) {
if (success) {
root.userAuthenticated(topic, id)
root.userAuthenticated(topic, id, password, pin)
} else {
root.userAuthenticationFailed(topic, id)
}