diff --git a/storybook/qmlTests/tests/tst_DAppsWorkflow.qml b/storybook/qmlTests/tests/tst_DAppsWorkflow.qml index e494c9e5b5..da7aa86b82 100644 --- a/storybook/qmlTests/tests/tst_DAppsWorkflow.qml +++ b/storybook/qmlTests/tests/tst_DAppsWorkflow.qml @@ -310,7 +310,9 @@ Item { function test_TestAuthentication() { let td = mockSessionRequestEvent(this, handler.sdk, handler.accountsModel, handler.networksModel) - handler.authenticate(td.request) + handler.sdk.sessionRequestEvent(td.request.event) + let request = handler.requestsModel.findById(td.request.requestId) + request.accept() compare(handler.store.authenticateUserCalls.length, 1, "expected a call to store.authenticateUser") let store = handler.store @@ -532,6 +534,7 @@ Item { verify(sdk.rejectSessionRequestCalls.length === 0, "expected no call to sdk.rejectSessionRequest") ignoreWarning("Error: request expired") + request.accept() handler.store.userAuthenticated(topic, session.id, "1234", "", message) verify(sdk.rejectSessionRequestCalls.length === 1, "expected a call to sdk.rejectSessionRequest") sdk.sessionRequestUserAnswerResult(topic, session.id, false, "") @@ -558,6 +561,7 @@ Item { verify(sdk.rejectSessionRequestCalls.length === 0, "expected no call to sdk.rejectSessionRequest") ignoreWarning("Error: request expired") + handler.requestsModel.findRequest(topic, session.id).accept() handler.store.userAuthenticationFailed(topic, session.id) verify(sdk.rejectSessionRequestCalls.length === 1, "expected a call to sdk.rejectSessionRequest") } @@ -581,6 +585,7 @@ Item { verify(sdk.rejectSessionRequestCalls.length === 0, "expected no call to sdk.rejectSessionRequest") ignoreWarning("Error: request expired") + handler.requestsModel.findRequest(topic, session.id).accept() handler.store.userAuthenticationFailed(topic, session.id) verify(sdk.rejectSessionRequestCalls.length === 1, "expected a call to sdk.rejectSessionRequest") } @@ -708,9 +713,8 @@ Item { function test_TestPairingUnsupportedNetworks() { const {sdk, walletStore, store} = testSetupPair(Testing.formatSessionProposal()) - let allApprovedNamespaces = JSON.parse(Testing.formatBuildApprovedNamespacesResult([], [])) const approvedArgs = sdk.buildApprovedNamespacesCalls[0] - sdk.buildApprovedNamespacesResult(approvedArgs.id, allApprovedNamespaces, "") + sdk.buildApprovedNamespacesResult(approvedArgs.id, {}, "Non conforming namespaces. approve() namespaces chains don't satisfy required namespaces") compare(connectDAppSpy.count, 0, "expected not to have calls to service.connectDApp") compare(service.onPairingValidatedTriggers.length, 1, "expected a call to service.onPairingValidated") compare(service.onPairingValidatedTriggers[0].validationState, Pairing.errors.unsupportedNetwork, "expected unsupportedNetwork state error") @@ -794,8 +798,6 @@ Item { // Implemented as a regression to metamask not having icons which failed dapps list function test_TestUpdateDapps() { - provider.updateDapps() - // Validate that persistance fallback is working compare(provider.dappsModel.count, 2, "expected dappsModel have the right number of elements") let persistanceList = JSON.parse(dappsListReceivedJsonStr) diff --git a/storybook/qmlTests/tst_SessionRequestWithAuth.qml b/storybook/qmlTests/tst_SessionRequestWithAuth.qml new file mode 100644 index 0000000000..1bc78cf260 --- /dev/null +++ b/storybook/qmlTests/tst_SessionRequestWithAuth.qml @@ -0,0 +1,155 @@ +import QtQuick 2.15 + +import QtTest 1.15 + +import AppLayouts.Wallet.services.dapps.types 1.0 + +import shared.stores 1.0 + +Item { + id: root + + width: 600 + height: 400 + + + Component { + id: dappsStoreComponent + + DAppsStore { + signal userAuthenticated(string topic, string id, string password, string pin) + signal userAuthenticationFailed(string topic, string id) + + property var authenticateUserCalls: [] + function authenticateUser(topic, id, address) { + authenticateUserCalls.push({topic, id, address}) + } + } + } + + Component { + id: sessionRequestComponent + + SessionRequestWithAuth { + id: sessionRequest + readonly property SignalSpy executeSpy: SignalSpy { target: sessionRequest; signalName: "execute" } + readonly property SignalSpy rejectedSpy: SignalSpy { target: sessionRequest; signalName: "rejected" } + readonly property SignalSpy authFailedSpy: SignalSpy { target: sessionRequest; signalName: "authFailed" } + + // SessionRequestResolved required properties + // Not of interest for this test + event: "event" + topic: "topic" + requestId: "id" + method: "method" + accountAddress: "address" + chainId: "chainID" + sourceId: 0 + data: "data" + preparedData: "preparedData" + } + } + + TestCase { + id: sessionRequestTest + name: "SessionRequestWithAuth" + // Ensure mocked GroupedAccountsAssetsModel is properly initialized + when: windowShown + + property SessionRequestWithAuth componentUnderTest: null + + function init() { + const store = createTemporaryObject(dappsStoreComponent, root) + componentUnderTest = createTemporaryObject(sessionRequestComponent, root, { store }) + } + + function test_acceptAndAuthenticated() { + componentUnderTest.accept() + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 0) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 1) + + componentUnderTest.store.userAuthenticated("topic", "id", "password", "pin") + + compare(componentUnderTest.executeSpy.count, 1) + compare(componentUnderTest.rejectedSpy.count, 0) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 1) + } + + function test_AcceptAndAuthFails() { + componentUnderTest.accept() + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 0) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 1) + + componentUnderTest.store.userAuthenticationFailed("topic", "id") + + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 1) + compare(componentUnderTest.authFailedSpy.count, 1) + compare(componentUnderTest.store.authenticateUserCalls.length, 1) + } + + function test_AcceptRequestExpired() { + ignoreWarning("Error: request expired") + componentUnderTest.expirationTimestamp = Date.now() / 1000 - 1 + componentUnderTest.accept() + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 1) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 0) + } + + function test_AcceptAndReject() { + componentUnderTest.accept() + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 0) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 1) + + componentUnderTest.reject(false) + + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 1) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 1) + } + + function test_AcceptAndExpiresAfterAuth() { + ignoreWarning("Error: request expired") + componentUnderTest.accept() + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 0) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 1) + + componentUnderTest.expirationTimestamp = Date.now() / 1000 - 1 + componentUnderTest.store.userAuthenticated("topic", "id", "password", "pin") + + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 1) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 1) + } + + function test_Reject() { + componentUnderTest.reject(false) + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 1) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 0) + } + + function test_RejectExpiredRequest() { + componentUnderTest.expirationTimestamp = Date.now() / 1000 - 1 + componentUnderTest.reject(false) + compare(componentUnderTest.executeSpy.count, 0) + compare(componentUnderTest.rejectedSpy.count, 1) + compare(componentUnderTest.authFailedSpy.count, 0) + compare(componentUnderTest.store.authenticateUserCalls.length, 0) + } + } +} \ No newline at end of file diff --git a/storybook/qmlTests/tst_SiweLifeCycle.qml b/storybook/qmlTests/tst_SiweLifeCycle.qml new file mode 100644 index 0000000000..d038d8b1ef --- /dev/null +++ b/storybook/qmlTests/tst_SiweLifeCycle.qml @@ -0,0 +1,487 @@ +import QtQuick 2.15 + +import QtTest 1.15 + +import AppLayouts.Wallet.services.dapps 1.0 +import AppLayouts.Wallet.services.dapps.plugins 1.0 + +import shared.stores 1.0 + +Item { + id: root + + width: 600 + height: 400 + + Component { + id: siweLifeCycleComponent + SiweLifeCycle { + id: siweLifeCycle + readonly property SignalSpy startedSpy: SignalSpy { target: siweLifeCycle; signalName: "started" } + readonly property SignalSpy finishedSpy: SignalSpy { target: siweLifeCycle; signalName: "finished" } + readonly property SignalSpy requestSessionApprovalSpy: SignalSpy { target: siweLifeCycle; signalName: "requestSessionApproval" } + readonly property SignalSpy registerSignRequestSpy: SignalSpy { target: siweLifeCycle; signalName: "registerSignRequest" } + readonly property SignalSpy unregisterSignRequestSpy: SignalSpy { target: siweLifeCycle; signalName: "unregisterSignRequest" } + + sdk: WalletConnectSDKBase { + id: sdkMock + + projectId: "projectId" + + property var getActiveSessionCalls: [] + getActiveSessions: function(callback) { + getActiveSessionCalls.push(callback) + } + + property var populateAuthPayloadCalls: [] + populateAuthPayload: function(id, payload, chains, methods) { + populateAuthPayloadCalls.push({id, payload, chains, methods}) + } + + property var formatAuthMessageCalls: [] + formatAuthMessage: function(id, request, iss) { + formatAuthMessageCalls.push({id, request, iss}) + } + + property var acceptSessionAuthenticateCalls: [] + acceptSessionAuthenticate: function(id, auths) { + acceptSessionAuthenticateCalls.push({id, auths}) + } + + property var rejectSessionAuthenticateCalls: [] + rejectSessionAuthenticate: function(id, error) { + rejectSessionAuthenticateCalls.push({id, error}) + } + + property var buildAuthObjectCalls: [] + buildAuthObject: function(id, payload, signedData, account) { + buildAuthObjectCalls.push({id, payload, signedData, account}) + } + } + store: DAppsStore { + id: dappsStoreMock + signal userAuthenticated(string topic, string id, string password, string pin) + signal userAuthenticationFailed(string topic, string id) + signal signingResult(string topic, string id, string data) + + property var authenticateUserCalls: [] + function authenticateUser(topic, id, address) { + authenticateUserCalls.push({topic, id, address}) + } + + property var signMessageCalls: [] + function signMessage(topic, id, address, data, password, pin) { + signMessageCalls.push({topic, id, address, data, password, pin}) + } + } + request: buildSiweRequestMessage() + accountsModel: ListModel { + ListElement { chainId: 1 } + ListElement { chainId: 2 } + } + networksModel: ListModel { + ListElement { address: "0x1" } + ListElement { address: "0x2" } + } + } + } + + function buildSiweRequestMessage() { + const timestamp = Date.now() / 1000 + 1000 + return { + "id":1729244859941412, + "params": { + "authPayload": { + "aud":"https://appkit-lab.reown.com", + "chains":["eip155:1","eip155:10","eip155:137","eip155:324","eip155:42161","eip155:8453","eip155:84532","eip155:1301","eip155:11155111","eip155:100","eip155:295"], + "domain":"appkit-lab.reown.com", + "iat":"2024-10-18T09:47:39.941Z", + "nonce":"e2f9d65105e06be0b3a86a675cf90c7a28a8c6d9d0fb84c2a1187c15ef27f120", + "resources":["urn:recap:eyJhdHQiOnsiZWlwMTU1Ijp7InJlcXVlc3QvZXRoX2FjY291bnRzIjpbe31dLCJyZXF1ZXN0L2V0aF9yZXF1ZXN0QWNjb3VudHMiOlt7fV0sInJlcXVlc3QvZXRoX3NlbmRSYXdUcmFuc2FjdGlvbiI6W3t9XSwicmVxdWVzdC9ldGhfc2VuZFRyYW5zYWN0aW9uIjpbe31dLCJyZXF1ZXN0L2V0aF9zaWduIjpbe31dLCJyZXF1ZXN0L2V0aF9zaWduVHJhbnNhY3Rpb24iOlt7fV0sInJlcXVlc3QvZXRoX3NpZ25UeXBlZERhdGEiOlt7fV0sInJlcXVlc3QvZXRoX3NpZ25UeXBlZERhdGFfdjMiOlt7fV0sInJlcXVlc3QvZXRoX3NpZ25UeXBlZERhdGFfdjQiOlt7fV0sInJlcXVlc3QvcGVyc29uYWxfc2lnbiI6W3t9XSwicmVxdWVzdC93YWxsZXRfYWRkRXRoZXJldW1DaGFpbiI6W3t9XSwicmVxdWVzdC93YWxsZXRfZ2V0Q2FsbHNTdGF0dXMiOlt7fV0sInJlcXVlc3Qvd2FsbGV0X2dldENhcGFiaWxpdGllcyI6W3t9XSwicmVxdWVzdC93YWxsZXRfZ2V0UGVybWlzc2lvbnMiOlt7fV0sInJlcXVlc3Qvd2FsbGV0X2dyYW50UGVybWlzc2lvbnMiOlt7fV0sInJlcXVlc3Qvd2FsbGV0X3JlZ2lzdGVyT25ib2FyZGluZyI6W3t9XSwicmVxdWVzdC93YWxsZXRfcmVxdWVzdFBlcm1pc3Npb25zIjpbe31dLCJyZXF1ZXN0L3dhbGxldF9zY2FuUVJDb2RlIjpbe31dLCJyZXF1ZXN0L3dhbGxldF9zZW5kQ2FsbHMiOlt7fV0sInJlcXVlc3Qvd2FsbGV0X3N3aXRjaEV0aGVyZXVtQ2hhaW4iOlt7fV0sInJlcXVlc3Qvd2FsbGV0X3dhdGNoQXNzZXQiOlt7fV19fX0"], + "statement":"Please sign with your account", + "type":"caip122", + "version":"1" + }, + "expiryTimestamp": timestamp, + "requester": { + "metadata": { + "description":"Explore the AppKit Lab to test the latest AppKit features.", + "icons":["https://appkit-lab.reown.com/favicon.svg"], + "name":"AppKit Lab", + "url":"https://appkit-lab.reown.com" + }, + "publicKey":"205aeb6376a2d79e8b0fa02aa8123473a7822a30ea3dc9d7be9b3de4e31e9f2b" + } + }, + "topic":"c525f017208ca3b4ad53928c16bab48d03af42c6cd47608c5fd73703bf5700bb", + "verifyContext": { + "verified": { + "isScam":null, + "origin":"https://appkit-lab.reown.com", + "validation":"VALID", + "verifyUrl":"https://verify.walletconnect.org" + } + } + } + } + + function buildSiweAuthPayload() { + return { + "aud":"https://appkit-lab.reown.com", + "chains":["eip155:1","eip155:10","eip155:42161"], + "domain":"appkit-lab.reown.com","iat":"2024-10-18T09:47:39.941Z", + "nonce":"e2f9d65105e06be0b3a86a675cf90c7a28a8c6d9d0fb84c2a1187c15ef27f120", + "resources":["urn:recap:eyJhdHQiOnsiZWlwMTU1Ijp7InJlcXVlc3QvZXRoX3NlbmRUcmFuc2FjdGlvbiI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnbiI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnblR5cGVkRGF0YSI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnblR5cGVkRGF0YV92NCI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9wZXJzb25hbF9zaWduIjpbeyJjaGFpbnMiOlsiZWlwMTU1OjEiLCJlaXAxNTU6MTAiLCJlaXAxNTU6NDIxNjEiXX1dfX19"], + "statement":"Please sign with your account I further authorize the stated URI to perform the following actions on my behalf: (1) 'request': 'eth_sendTransaction', 'eth_sign', 'eth_signTypedData', 'eth_signTypedData_v4', 'personal_sign' for 'eip155'.", + "type":"caip122", + "version":"1" + } + } + + function formattedMessage() { + return "appkit-lab.reown.com wants you to sign in with your Ethereum account:\n0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240\n\nPlease sign with your account I further authorize the stated URI to perform the following actions on my behalf: (1) 'request': 'eth_sendTransaction', 'eth_sign', 'eth_signTypedData', 'eth_signTypedData_v4', 'personal_sign' for 'eip155'.\n\nURI: https://appkit-lab.reown.com\nVersion: 1\nChain ID: 1\nNonce: e2f9d65105e06be0b3a86a675cf90c7a28a8c6d9d0fb84c2a1187c15ef27f120\nIssued At: 2024-10-18T09:47:39.941Z\nResources:\n- urn:recap:eyJhdHQiOnsiZWlwMTU1Ijp7InJlcXVlc3QvZXRoX3NlbmRUcmFuc2FjdGlvbiI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnbiI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnblR5cGVkRGF0YSI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnblR5cGVkRGF0YV92NCI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9wZXJzb25hbF9zaWduIjpbeyJjaGFpbnMiOlsiZWlwMTU1OjEiLCJlaXAxNTU6MTAiLCJlaXAxNTU6NDIxNjEiXX1dfX19" + } + + TestCase { + id: siweLifeCycleTest + name: "SiweLifeCycle" + + property SiweLifeCycle componentUnderTest: null + + function init() { + componentUnderTest = createTemporaryObject(siweLifeCycleComponent, root) + } + + function test_EndToEndSuccessful() { + const requestEvent = componentUnderTest.request + componentUnderTest.start() + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + compare(componentUnderTest.requestSessionApprovalSpy.count, 0) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + compare(componentUnderTest.unregisterSignRequestSpy.count, 0) + + // Step 1: Get the active sessions from the SDK + compare(componentUnderTest.sdk.getActiveSessionCalls.length, 1) + componentUnderTest.sdk.getActiveSessionCalls[0]({}) + + // Step 2: Request chains and accounts approval + compare(componentUnderTest.requestSessionApprovalSpy.count, 1) + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + compare(componentUnderTest.unregisterSignRequestSpy.count, 0) + + const key = requestEvent.id + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + componentUnderTest.sessionApproved(key, { eip155: { + chains, + accounts, + methods + }}) + + // Step 3: Populate the auth payload + compare(componentUnderTest.sdk.populateAuthPayloadCalls.length, 1) + verify(componentUnderTest.sdk.populateAuthPayloadCalls[0].id == key) + compare(componentUnderTest.sdk.populateAuthPayloadCalls[0].payload, requestEvent.params.authPayload) + compare(componentUnderTest.sdk.populateAuthPayloadCalls[0].chains, chains) + compare(componentUnderTest.sdk.populateAuthPayloadCalls[0].methods, methods) + // No extra event is sent + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + compare(componentUnderTest.requestSessionApprovalSpy.count, 1) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + compare(componentUnderTest.unregisterSignRequestSpy.count, 0) + + // Step 4: Format the auth message + const authPayload = buildSiweAuthPayload() + componentUnderTest.sdk.populateAuthPayloadResult(key, authPayload, "") + compare(componentUnderTest.sdk.formatAuthMessageCalls.length, 1) + verify(componentUnderTest.sdk.formatAuthMessageCalls[0].id == key) + compare(componentUnderTest.sdk.formatAuthMessageCalls[0].request, authPayload) + compare(componentUnderTest.sdk.formatAuthMessageCalls[0].iss, "eip155:1:0x1") + // No extra event is sent + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + compare(componentUnderTest.requestSessionApprovalSpy.count, 1) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + compare(componentUnderTest.unregisterSignRequestSpy.count, 0) + + // Step 5: Accept the session authentication and sign the message + componentUnderTest.sdk.formatAuthMessageResult(key, formattedMessage(), "") + compare(componentUnderTest.registerSignRequestSpy.count, 1) + // No extra event is sent + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + compare(componentUnderTest.requestSessionApprovalSpy.count, 1) + compare(componentUnderTest.unregisterSignRequestSpy.count, 0) + + const request = componentUnderTest.registerSignRequestSpy.signalArguments[0][0] + verify(!!request) + request.execute("password", "pin") + compare(componentUnderTest.store.signMessageCalls.length, 1) + componentUnderTest.store.signingResult(requestEvent.topic, requestEvent.id, "signedData") + + // Step 6: Build the response + compare(componentUnderTest.sdk.buildAuthObjectCalls.length, 1) + verify(componentUnderTest.sdk.buildAuthObjectCalls[0].id == key) + compare(componentUnderTest.sdk.buildAuthObjectCalls[0].payload, authPayload) + compare(componentUnderTest.sdk.buildAuthObjectCalls[0].signedData, "signedData") + compare(componentUnderTest.sdk.buildAuthObjectCalls[0].account, "eip155:1:0x1") + + componentUnderTest.sdk.buildAuthObjectResult(key, "authObject", "") + + // Step 7: Accept the session authentication + compare(componentUnderTest.sdk.acceptSessionAuthenticateCalls.length, 1) + verify(componentUnderTest.sdk.acceptSessionAuthenticateCalls[0].id == key) + compare(componentUnderTest.sdk.acceptSessionAuthenticateCalls[0].auths, ["authObject"]) + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + + // Step 8: Finish the process + componentUnderTest.sdk.acceptSessionAuthenticateResult(key, "", "") + compare(componentUnderTest.finishedSpy.count, 1) + } + + function test_StartExpired() { + ignoreWarning(new RegExp(/^Error in SiweLifeCycle/)) + const expiredRequest = buildSiweRequestMessage() + expiredRequest.params.expiryTimestamp = Date.now() / 1000 - 1 + componentUnderTest.request = expiredRequest + + componentUnderTest.start() + compare(componentUnderTest.startedSpy.count, 0) + compare(componentUnderTest.finishedSpy.count, 1) + compare(componentUnderTest.requestSessionApprovalSpy.count, 0) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + compare(componentUnderTest.unregisterSignRequestSpy.count, 1) + compare(componentUnderTest.sdk.getActiveSessionCalls.length, 0) + compare(componentUnderTest.sdk.populateAuthPayloadCalls.length, 0) + compare(componentUnderTest.sdk.formatAuthMessageCalls.length, 0) + compare(componentUnderTest.sdk.buildAuthObjectCalls.length, 0) + compare(componentUnderTest.sdk.acceptSessionAuthenticateCalls.length, 0) + } + + function test_StartWithExistingSession() { + ignoreWarning(new RegExp(/^Error in SiweLifeCycle/)) + const requestEvent = componentUnderTest.request + componentUnderTest.start() + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + compare(componentUnderTest.requestSessionApprovalSpy.count, 0) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + compare(componentUnderTest.unregisterSignRequestSpy.count, 0) + + // Step 1: Get the active sessions from the SDK + // return an existing session with the same topic + compare(componentUnderTest.sdk.getActiveSessionCalls.length, 1) + componentUnderTest.sdk.getActiveSessionCalls[0]({ [requestEvent.topic]: { }}) + compare(componentUnderTest.finishedSpy.count, 1) + } + + function test_StartWithInvalidSession() { + // regex to check if the warning starts with "Error in SiweLifeCycle" + ignoreWarning(new RegExp(/^Error in SiweLifeCycle/)) + const requestEvent = componentUnderTest.request + componentUnderTest.start() + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + compare(componentUnderTest.requestSessionApprovalSpy.count, 0) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + compare(componentUnderTest.unregisterSignRequestSpy.count, 0) + + // Step 1: Get the active sessions from the SDK + // return an existing session with the same topic + compare(componentUnderTest.sdk.getActiveSessionCalls.length, 1) + componentUnderTest.sdk.getActiveSessionCalls[0]("invalidresponse") + compare(componentUnderTest.finishedSpy.count, 1) + } + + function test_StartWithInvalidRequest() { + // regex to check if the warning starts with "Error in SiweLifeCycle" + ignoreWarning(new RegExp(/^Error in SiweLifeCycle/)) + componentUnderTest.request = {} + componentUnderTest.start() + compare(componentUnderTest.startedSpy.count, 0) + compare(componentUnderTest.finishedSpy.count, 1) + compare(componentUnderTest.requestSessionApprovalSpy.count, 0) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + compare(componentUnderTest.unregisterSignRequestSpy.count, 1) + compare(componentUnderTest.sdk.getActiveSessionCalls.length, 0) + } + + function test_RejectedSessionApproval() { + const requestEvent = componentUnderTest.request + componentUnderTest.start() + componentUnderTest.sdk.getActiveSessionCalls[0]({}) + + compare(componentUnderTest.requestSessionApprovalSpy.count, 1) + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + + const key = requestEvent.id + componentUnderTest.sessionRejected(key) + compare(componentUnderTest.finishedSpy.count, 1) + } + + function test_RejectSign() { + const requestEvent = componentUnderTest.request + componentUnderTest.start() + componentUnderTest.sdk.getActiveSessionCalls[0]({}) + + compare(componentUnderTest.requestSessionApprovalSpy.count, 1) + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + + const key = requestEvent.id + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + componentUnderTest.sessionApproved(key, { eip155: { + chains, + accounts, + methods + }}) + componentUnderTest.sdk.populateAuthPayloadResult(key, buildSiweAuthPayload(), "") + componentUnderTest.sdk.formatAuthMessageResult(key, formattedMessage(), "") + const request = componentUnderTest.registerSignRequestSpy.signalArguments[0][0] + request.reject(false) + compare(componentUnderTest.finishedSpy.count, 1) + } + + function test_AuthenticationFails() { + const requestEvent = componentUnderTest.request + componentUnderTest.start() + componentUnderTest.sdk.getActiveSessionCalls[0]({}) + + compare(componentUnderTest.requestSessionApprovalSpy.count, 1) + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + + const key = requestEvent.id + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + componentUnderTest.sessionApproved(key, { eip155: { + chains, + accounts, + methods + }}) + componentUnderTest.sdk.populateAuthPayloadResult(key, buildSiweAuthPayload(), "") + componentUnderTest.sdk.formatAuthMessageResult(key, formattedMessage(), "") + const request = componentUnderTest.registerSignRequestSpy.signalArguments[0][0] + request.reject(false) + componentUnderTest.store.userAuthenticationFailed(requestEvent.topic, requestEvent.id) + compare(componentUnderTest.finishedSpy.count, 1) + } + + function test_InvalidPopulatedAuthPayload() { + const requestEvent = componentUnderTest.request + componentUnderTest.start() + componentUnderTest.sdk.getActiveSessionCalls[0]({}) + + compare(componentUnderTest.requestSessionApprovalSpy.count, 1) + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + + const key = requestEvent.id + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + componentUnderTest.sessionApproved(key, { eip155: { + chains, + accounts, + methods + }}) + componentUnderTest.sdk.populateAuthPayloadResult(key, undefined, "") + compare(componentUnderTest.finishedSpy.count, 1) + } + + function test_invalidFormatAuthMessage() { + const requestEvent = componentUnderTest.request + componentUnderTest.start() + componentUnderTest.sdk.getActiveSessionCalls[0]({}) + + compare(componentUnderTest.requestSessionApprovalSpy.count, 1) + compare(componentUnderTest.startedSpy.count, 1) + compare(componentUnderTest.finishedSpy.count, 0) + + const key = requestEvent.id + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + componentUnderTest.sessionApproved(key, { eip155: { + chains, + accounts, + methods + }}) + componentUnderTest.sdk.populateAuthPayloadResult(key, buildSiweAuthPayload(), "") + componentUnderTest.sdk.formatAuthMessageResult(key, undefined, "") + compare(componentUnderTest.finishedSpy.count, 1) + } + + function test_CallsWithDifferentId() { + const requestEvent = componentUnderTest.request + componentUnderTest.start() + componentUnderTest.sdk.getActiveSessionCalls[0]({}) + const key = requestEvent.id + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + // wrong key + componentUnderTest.sessionApproved(key + 1, { eip155: { + chains, + accounts, + methods + }}) + compare(componentUnderTest.sdk.populateAuthPayloadCalls.length, 0) + //correct key + componentUnderTest.sessionApproved(key, { eip155: { + chains, + accounts, + methods + }}) + compare(componentUnderTest.sdk.populateAuthPayloadCalls.length, 1) + + // wrong key + componentUnderTest.sdk.populateAuthPayloadResult(key + 1, buildSiweAuthPayload(), "") + compare(componentUnderTest.sdk.formatAuthMessageCalls.length, 0) + // correct key + componentUnderTest.sdk.populateAuthPayloadResult(key, buildSiweAuthPayload(), "") + compare(componentUnderTest.sdk.formatAuthMessageCalls.length, 1) + + // wrong key + componentUnderTest.sdk.formatAuthMessageResult(key + 1, formattedMessage(), "") + compare(componentUnderTest.registerSignRequestSpy.count, 0) + // correct key + componentUnderTest.sdk.formatAuthMessageResult(key, formattedMessage(), "") + compare(componentUnderTest.registerSignRequestSpy.count, 1) + + const request = componentUnderTest.registerSignRequestSpy.signalArguments[0][0] + request.execute("password", "pin") + + // wrong key + componentUnderTest.store.signingResult(requestEvent.topic, requestEvent.id + 1, "signedData") + compare(componentUnderTest.sdk.buildAuthObjectCalls.length, 0) + // correct key + componentUnderTest.store.signingResult(requestEvent.topic, requestEvent.id, "signedData") + compare(componentUnderTest.sdk.buildAuthObjectCalls.length, 1) + + // wrong key + componentUnderTest.sdk.buildAuthObjectResult(key + 1, "authObject", "") + compare(componentUnderTest.sdk.acceptSessionAuthenticateCalls.length, 0) + // correct key + componentUnderTest.sdk.buildAuthObjectResult(key, "authObject", "") + compare(componentUnderTest.sdk.acceptSessionAuthenticateCalls.length, 1) + + // wrong key + componentUnderTest.sdk.acceptSessionAuthenticateResult(key + 1, "", "") + compare(componentUnderTest.finishedSpy.count, 0) + // correct key + componentUnderTest.sdk.acceptSessionAuthenticateResult(key, "", "") + compare(componentUnderTest.finishedSpy.count, 1) + } + } +} \ No newline at end of file diff --git a/storybook/qmlTests/tst_SiweRequestPlugin.qml b/storybook/qmlTests/tst_SiweRequestPlugin.qml new file mode 100644 index 0000000000..00dc57a53b --- /dev/null +++ b/storybook/qmlTests/tst_SiweRequestPlugin.qml @@ -0,0 +1,245 @@ +import QtQuick 2.15 + +import QtTest 1.15 + +import AppLayouts.Wallet.services.dapps 1.0 +import AppLayouts.Wallet.services.dapps.plugins 1.0 + +import shared.stores 1.0 + +Item { + id: root + + width: 600 + height: 400 + + Component { + id: siweRequestPlugin + + SiweRequestPlugin { + id: siwePlugin + + readonly property SignalSpy connectDAppSpy: SignalSpy { target: siwePlugin; signalName: "connectDApp" } + readonly property SignalSpy registerSignRequestSpy: SignalSpy { target: siwePlugin; signalName: "registerSignRequest" } + readonly property SignalSpy unregisterSignRequestSpy: SignalSpy { target: siwePlugin; signalName: "unregisterSignRequest" } + readonly property SignalSpy siweSuccessfulSpy: SignalSpy { target: siwePlugin; signalName: "siweSuccessful" } + readonly property SignalSpy siweFailedSpy: SignalSpy { target: siwePlugin; signalName: "siweFailed" } + + sdk: WalletConnectSDKBase { + id: sdkMock + + projectId: "projectId" + + getActiveSessions: function(callback) { + callback({}) + } + + populateAuthPayload: function(id, payload, chains, methods) { + sdkMock.populateAuthPayloadResult(id, {}, "") + } + + formatAuthMessage: function(id, request, iss) { + sdkMock.formatAuthMessageResult(id, {}, "") + } + + acceptSessionAuthenticate: function(id, auths) { + sdkMock.acceptSessionAuthenticateResult(id, {}, "") + } + + rejectSessionAuthenticate: function(id, error) { + sdkMock.rejectSessionAuthenticateResult(id, {}, "") + } + + buildAuthObject: function(id, payload, signedData, account) { + sdkMock.buildAuthObjectResult(id, {}, "") + } + } + store: DAppsStore { + id: dappsStoreMock + signal userAuthenticated(string topic, string id, string password, string pin) + signal userAuthenticationFailed(string topic, string id) + signal signingResult(string topic, string id, string data) + + property var authenticateUserCalls: [] + function authenticateUser(topic, id, address) { + authenticateUserCalls.push({topic, id, address}) + } + + property var signMessageCalls: [] + function signMessage(topic, id, address, data, password, pin) { + signMessageCalls.push({topic, id, address, data, password, pin}) + } + } + accountsModel: ListModel { + ListElement { chainId: 1 } + ListElement { chainId: 2 } + } + networksModel: ListModel { + ListElement { address: "0x1" } + ListElement { address: "0x2" } + } + } + } + + function buildSiweRequestMessage() { + const timestamp = Date.now() / 1000 + 1000 + return { + "id":1729244859941412, + "params": { + "authPayload": { + "aud":"https://appkit-lab.reown.com", + "chains":["eip155:1","eip155:10","eip155:137","eip155:324","eip155:42161","eip155:8453","eip155:84532","eip155:1301","eip155:11155111","eip155:100","eip155:295"], + "domain":"appkit-lab.reown.com", + "iat":"2024-10-18T09:47:39.941Z", + "nonce":"e2f9d65105e06be0b3a86a675cf90c7a28a8c6d9d0fb84c2a1187c15ef27f120", + "resources":["urn:recap:eyJhdHQiOnsiZWlwMTU1Ijp7InJlcXVlc3QvZXRoX2FjY291bnRzIjpbe31dLCJyZXF1ZXN0L2V0aF9yZXF1ZXN0QWNjb3VudHMiOlt7fV0sInJlcXVlc3QvZXRoX3NlbmRSYXdUcmFuc2FjdGlvbiI6W3t9XSwicmVxdWVzdC9ldGhfc2VuZFRyYW5zYWN0aW9uIjpbe31dLCJyZXF1ZXN0L2V0aF9zaWduIjpbe31dLCJyZXF1ZXN0L2V0aF9zaWduVHJhbnNhY3Rpb24iOlt7fV0sInJlcXVlc3QvZXRoX3NpZ25UeXBlZERhdGEiOlt7fV0sInJlcXVlc3QvZXRoX3NpZ25UeXBlZERhdGFfdjMiOlt7fV0sInJlcXVlc3QvZXRoX3NpZ25UeXBlZERhdGFfdjQiOlt7fV0sInJlcXVlc3QvcGVyc29uYWxfc2lnbiI6W3t9XSwicmVxdWVzdC93YWxsZXRfYWRkRXRoZXJldW1DaGFpbiI6W3t9XSwicmVxdWVzdC93YWxsZXRfZ2V0Q2FsbHNTdGF0dXMiOlt7fV0sInJlcXVlc3Qvd2FsbGV0X2dldENhcGFiaWxpdGllcyI6W3t9XSwicmVxdWVzdC93YWxsZXRfZ2V0UGVybWlzc2lvbnMiOlt7fV0sInJlcXVlc3Qvd2FsbGV0X2dyYW50UGVybWlzc2lvbnMiOlt7fV0sInJlcXVlc3Qvd2FsbGV0X3JlZ2lzdGVyT25ib2FyZGluZyI6W3t9XSwicmVxdWVzdC93YWxsZXRfcmVxdWVzdFBlcm1pc3Npb25zIjpbe31dLCJyZXF1ZXN0L3dhbGxldF9zY2FuUVJDb2RlIjpbe31dLCJyZXF1ZXN0L3dhbGxldF9zZW5kQ2FsbHMiOlt7fV0sInJlcXVlc3Qvd2FsbGV0X3N3aXRjaEV0aGVyZXVtQ2hhaW4iOlt7fV0sInJlcXVlc3Qvd2FsbGV0X3dhdGNoQXNzZXQiOlt7fV19fX0"], + "statement":"Please sign with your account", + "type":"caip122", + "version":"1" + }, + "expiryTimestamp": timestamp, + "requester": { + "metadata": { + "description":"Explore the AppKit Lab to test the latest AppKit features.", + "icons":["https://appkit-lab.reown.com/favicon.svg"], + "name":"AppKit Lab", + "url":"https://appkit-lab.reown.com" + }, + "publicKey":"205aeb6376a2d79e8b0fa02aa8123473a7822a30ea3dc9d7be9b3de4e31e9f2b" + } + }, + "topic":"c525f017208ca3b4ad53928c16bab48d03af42c6cd47608c5fd73703bf5700bb", + "verifyContext": { + "verified": { + "isScam":null, + "origin":"https://appkit-lab.reown.com", + "validation":"VALID", + "verifyUrl":"https://verify.walletconnect.org" + } + } + } + } + + // function buildSiweAuthPayload() { + // return { + // "aud":"https://appkit-lab.reown.com", + // "chains":["eip155:1","eip155:10","eip155:42161"], + // "domain":"appkit-lab.reown.com","iat":"2024-10-18T09:47:39.941Z", + // "nonce":"e2f9d65105e06be0b3a86a675cf90c7a28a8c6d9d0fb84c2a1187c15ef27f120", + // "resources":["urn:recap:eyJhdHQiOnsiZWlwMTU1Ijp7InJlcXVlc3QvZXRoX3NlbmRUcmFuc2FjdGlvbiI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnbiI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnblR5cGVkRGF0YSI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnblR5cGVkRGF0YV92NCI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9wZXJzb25hbF9zaWduIjpbeyJjaGFpbnMiOlsiZWlwMTU1OjEiLCJlaXAxNTU6MTAiLCJlaXAxNTU6NDIxNjEiXX1dfX19"], + // "statement":"Please sign with your account I further authorize the stated URI to perform the following actions on my behalf: (1) 'request': 'eth_sendTransaction', 'eth_sign', 'eth_signTypedData', 'eth_signTypedData_v4', 'personal_sign' for 'eip155'.", + // "type":"caip122", + // "version":"1" + // } + // } + + // function formattedMessage() { + // return "appkit-lab.reown.com wants you to sign in with your Ethereum account:\n0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240\n\nPlease sign with your account I further authorize the stated URI to perform the following actions on my behalf: (1) 'request': 'eth_sendTransaction', 'eth_sign', 'eth_signTypedData', 'eth_signTypedData_v4', 'personal_sign' for 'eip155'.\n\nURI: https://appkit-lab.reown.com\nVersion: 1\nChain ID: 1\nNonce: e2f9d65105e06be0b3a86a675cf90c7a28a8c6d9d0fb84c2a1187c15ef27f120\nIssued At: 2024-10-18T09:47:39.941Z\nResources:\n- urn:recap:eyJhdHQiOnsiZWlwMTU1Ijp7InJlcXVlc3QvZXRoX3NlbmRUcmFuc2FjdGlvbiI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnbiI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnblR5cGVkRGF0YSI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9ldGhfc2lnblR5cGVkRGF0YV92NCI6W3siY2hhaW5zIjpbImVpcDE1NToxIiwiZWlwMTU1OjEwIiwiZWlwMTU1OjQyMTYxIl19XSwicmVxdWVzdC9wZXJzb25hbF9zaWduIjpbeyJjaGFpbnMiOlsiZWlwMTU1OjEiLCJlaXAxNTU6MTAiLCJlaXAxNTU6NDIxNjEiXX1dfX19" + // } + + TestCase { + id: siwePlugin + name: "SiwePlugin" + + property SiweRequestPlugin componentUnderTest: null + + function init() { + componentUnderTest = createTemporaryObject(siweRequestPlugin, root) + } + + function test_NewValidRequest() { + const request = buildSiweRequestMessage() + const dAppUrl = request.params.requester.metadata.url + const dAppName = request.params.requester.metadata.name + const dAppIcon = request.params.requester.metadata.icons[0] + const key = request.id + + componentUnderTest.sdk.sessionAuthenticateRequest(request) + compare(componentUnderTest.connectDAppSpy.count, 1) + compare(componentUnderTest.connectDAppSpy.signalArguments[0][0], [1, 10, 137, 324, 42161, 8453, 84532, 1301, 11155111, 100, 295]) + compare(componentUnderTest.connectDAppSpy.signalArguments[0][1], dAppUrl) + compare(componentUnderTest.connectDAppSpy.signalArguments[0][2], dAppName) + compare(componentUnderTest.connectDAppSpy.signalArguments[0][3], dAppIcon) + compare(componentUnderTest.connectDAppSpy.signalArguments[0][4], key) + } + + function test_ApproveNewRequest() { + const request = buildSiweRequestMessage() + const key = request.id + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + componentUnderTest.sdk.sessionAuthenticateRequest(request) + componentUnderTest.connectionApproved(request.id, { eip155: { key, chains, accounts, methods }}) + compare(componentUnderTest.registerSignRequestSpy.count, 1) + const requestObj = componentUnderTest.registerSignRequestSpy.signalArguments[0][0] + requestObj.execute("pass", "pin") + componentUnderTest.store.signingResult(request.topic, request.id, "data") + tryCompare(componentUnderTest.siweSuccessfulSpy, "count", 1) + } + + function test_RejectNewRequest() { + const request = buildSiweRequestMessage() + const key = request.id + componentUnderTest.sdk.sessionAuthenticateRequest(request) + componentUnderTest.connectionRejected(request.id) + compare(componentUnderTest.unregisterSignRequestSpy.count, 1) + tryCompare(componentUnderTest.siweFailedSpy, "count", 1) + } + + function test_ApproveRequestExpired() { + ignoreWarning(new RegExp(/^Error in SiweLifeCycle/)) + const request = buildSiweRequestMessage() + const key = request.id + request.params.expiryTimestamp = Date.now() / 1000 - 1 + componentUnderTest.sdk.sessionAuthenticateRequest(request) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + tryCompare(componentUnderTest.siweFailedSpy, "count", 1) + } + + function test_ApproveWrongKey() { + const request = buildSiweRequestMessage() + const key = request.id + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + componentUnderTest.sdk.sessionAuthenticateRequest(request) + const ok = componentUnderTest.connectionApproved("wrongKey", { eip155: { key, chains, accounts, methods }}) + compare(ok, false) + } + + function test_RejectWrongKey() { + const request = buildSiweRequestMessage() + const key = request.id + componentUnderTest.sdk.sessionAuthenticateRequest(request) + const ok = componentUnderTest.connectionRejected("wrongKey") + compare(ok, false) + } + + function test_DoubleRequests() { + ignoreWarning(new RegExp(/^Error in SiweRequestPlugin/)) + const request = buildSiweRequestMessage() + const key = request.id + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + + componentUnderTest.sdk.sessionAuthenticateRequest(request) + componentUnderTest.sdk.sessionAuthenticateRequest(request) + + componentUnderTest.connectionApproved(request.id, { eip155: { chains, accounts, methods }}) + compare(componentUnderTest.registerSignRequestSpy.count, 1) + } + + function test_InvalidRequest() { + ignoreWarning(new RegExp(/^Error in SiweRequestPlugin/)) + const request = {"someRandomData": ""} + const chains = ["eip155:1", "eip155:10", "eip155:42161"] + const accounts = ["eip155:1:0x1", "eip155:1:0x2"] + const methods = ["eth_sendTransaction", "eth_sign", "eth_signTypedData", "eth_signTypedData_v4", "personal_sign"] + + componentUnderTest.sdk.sessionAuthenticateRequest(request) + compare(componentUnderTest.registerSignRequestSpy.count, 0) + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsListProvider.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsListProvider.qml index 28e42518dc..a1ab1704ca 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DAppsListProvider.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsListProvider.qml @@ -18,7 +18,8 @@ QObject { readonly property int connectorId: Constants.WalletConnect readonly property var dappsModel: d.dappsModel - function updateDapps() { + Component.onCompleted: { + // Just in case the SDK is already initialized d.updateDappsModel() } @@ -30,6 +31,29 @@ QObject { objectName: "DAppsModel" } + property Connections sdkConnections: Connections { + target: root.sdk + function onSessionDelete(topic, err) { + d.updateDappsModel() + } + function onSdkInit(success, result) { + if (success) { + d.updateDappsModel() + } + } + function onApproveSessionResult(topic, success, result) { + if (success) { + d.updateDappsModel() + } + } + + function onAcceptSessionAuthenticateResult(id, result, error) { + if (!error) { + d.updateDappsModel() + } + } + } + property var dappsListReceivedFn: null property var getActiveSessionsFn: null function updateDappsModel() diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml index 3b9133b27c..0951fa018e 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml @@ -1,6 +1,7 @@ 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 @@ -9,8 +10,6 @@ import StatusQ.Core.Utils 0.1 as SQUtils import shared.stores 1.0 import utils 1.0 -import "types" - SQUtils.QObject { id: root @@ -23,28 +22,222 @@ SQUtils.QObject { property alias requestsModel: requests - function rejectSessionRequest(topic, id, hasError) { - d.unsubscribeForFeeUpdates(topic, id) - sdk.rejectSessionRequest(topic, id, hasError) - } - function subscribeForFeeUpdates(topic, id) { d.subscribeForFeeUpdates(topic, id) } - /// Beware, it will fail if called multiple times before getting an answer - function authenticate(topic, id, address, payload) { - d.unsubscribeForFeeUpdates(topic, id) - return store.authenticateUser(topic, id, address, payload) + function pair(uri) { + return sdk.pair(uri) } - signal sessionRequest(string id) + /// 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) { @@ -83,7 +276,7 @@ SQUtils.QObject { if (error) { root.displayToastMessage(qsTr("Fail to %1 from %2").arg(methodStr).arg(appDomain), Constants.ephemeralNotificationType.danger) - root.rejectSessionRequest(topic, id, true /*hasError*/) + sdk.rejectSessionRequest(topic, id, true /*hasError*/) console.error(`Error accepting session request for topic: ${topic}, id: ${id}, accept: ${accept}, error: ${error}`) return } @@ -112,52 +305,56 @@ SQUtils.QObject { request.setExpired() } + + function onSessionDelete(topic, err) { + d.disconnectSessionRequested(topic, err) + } } - Connections { - target: root.store + SiweRequestPlugin { + id: siwePlugin - function onUserAuthenticated(topic, id, password, pin, payload) { - var request = requests.findRequest(topic, id) + 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("Error finding event for topic", topic, "id", id) + console.error("SiweRequestPlugin::onUnregisterSignRequest: Error finding event for requestId", requestId) return } - if (request.isExpired()) { - console.warn("Error: request expired") - root.rejectSessionRequest(topic, id, true /*hasError*/) - return - } - - d.executeSessionRequest(request, password, pin, payload) + requests.removeRequest(request.topic, requestId) } - function onUserAuthenticationFailed(topic, id) { - let request = requests.findRequest(topic, id) - let methodStr = SessionRequest.methodToUserString(request.method) - if (request === null || !methodStr) { - return - } - - if (request.isExpired()) { - console.warn("Error: request expired") - root.rejectSessionRequest(topic, id, true /*hasError*/) - return - } - - const appDomain = SQUtils.StringUtils.extractDomainFromLink(request.dappUrl) - root.displayToastMessage(qsTr("Failed to authenticate %1 from %2").arg(methodStr).arg(appDomain), Constants.ephemeralNotificationType.danger) - root.rejectSessionRequest(topic, id, true /*hasError*/) + onConnectDApp: (chains, dAppUrl, dAppName, dAppIcon, key) => { + root.connectDApp(chains, dAppUrl, dAppName, dAppIcon, key) } - function onSigningResult(topic, id, data) { - let hasErrors = (data == "") - if (!hasErrors) { - // acceptSessionRequest will trigger an sdk.sessionRequestUserAnswerResult signal - sdk.acceptSessionRequest(topic, id, data) - } else { - root.rejectSessionRequest(topic, id, hasErrors) - } + 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) + }) } } @@ -272,6 +469,10 @@ SQUtils.QObject { 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 @@ -845,6 +1046,44 @@ SQUtils.QObject { } 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 @@ -856,8 +1095,58 @@ SQUtils.QObject { Component { id: sessionRequestComponent - SessionRequestResolved { + 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: () => { + 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) + } + } } } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml index 251b4c7b8a..901e3eb4c5 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml @@ -56,11 +56,11 @@ QObject { function sign(topic, id) { // The authentication triggers the signing process // authenticate -> sign -> inform the dApp - d.authenticate(topic, id) + d.sign(topic, id) } function rejectSign(topic, id, hasError) { - requestHandler.rejectSessionRequest(topic, id, hasError) + d.rejectSign(topic, id, hasError) } function subscribeForFeeUpdates(topic, id) { @@ -75,29 +75,18 @@ QObject { /// Initiates the pairing process with the given URI function pair(uri) { timeoutTimer.start() - wcSDK.pair(uri) + requestHandler.pair(uri) } /// Approves or rejects the session proposal function approvePairSession(key, approvedChainIds, accountAddress) { - if (!d.activeProposals.has(key)) { - console.error("No active proposal found for key: " + key) - return - } - - const proposal = d.activeProposals.get(key) - d.acceptedSessionProposal = proposal - const approvedNamespaces = JSON.parse( - DAppsHelpers.buildSupportedNamespaces(approvedChainIds, - [accountAddress], - SessionRequest.getSupportedMethods()) - ) - wcSDK.buildApprovedNamespaces(key, proposal.params, approvedNamespaces) + requestHandler.approvePairSession(key, approvedChainIds, accountAddress) } + /// Rejects the session proposal function rejectPairSession(id) { - wcSDK.rejectSession(id) + requestHandler.rejectPairSession(id) } /// Disconnects the dApp with the given topic @@ -166,14 +155,6 @@ QObject { } } - property var activeProposals: new Map() // key: proposalId, value: sessionProposal - property var acceptedSessionProposal: null - - /// Disconnects the WC session with the given topic - function disconnectSession(sessionTopic) { - wcSDK.disconnectSession(sessionTopic) - } - function disconnectDapp(topic) { const dApp = d.getDAppByTopic(topic) if (!dApp) { @@ -196,7 +177,7 @@ QObject { if (dApp.connectorId === dappsProvider.connectorId) { // Currently disconnect acts on all sessions! for (let i = 0; i < dApp.sessions.ModelCount.count; i++) { - d.disconnectSession(dApp.sessions.get(i).topic) + requestHandler.disconnectSession(dApp.sessions.get(i).topic) } } } @@ -211,81 +192,31 @@ QObject { return } - const info = DAppsHelpers.extractInfoFromPairUri(uri) - wcSDK.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) - }); + requestHandler.validatePairingUri(uri) } - function authenticate(topic, id) { + function sign(topic, id) { const request = sessionRequestsModel.findRequest(topic, id) if (!request) { console.error("Session request not found") return } - requestHandler.authenticate(topic, id, request.accountAddress, request.feesInfo) + request.accept() + } + + function rejectSign(topic, id, hasError) { + const request = sessionRequestsModel.findRequest(topic, id) + if (!request) { + console.error("Session request not found") + return + } + request.reject(hasError) } function reportPairErrorState(state) { timeoutTimer.stop() root.pairingValidated(state) } - - 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) => { - store.activeSessionsReceived.disconnect(activeSessionsCallback) - - if (!success) { - // TODO #14754: implement custom dApp notification - d.notifyDappDisconnect("-", true) - return - } - - // Convert to original format - const webSdkSessions = allSessions.map((session) => { - return JSON.parse(session.sessionJson) - }) - - const sessions = DAppsHelpers.filterActiveSessionsForKnownAccounts(webSdkSessions, root.validAccounts) - - for (const sessionID in sessions) { - const session = sessions[sessionID] - if (session.topic === topic) { - store.deactivateWalletConnectSession(topic) - dappsProvider.updateDapps() - - const dappUrl = session.peer.metadata.url ?? "-" - d.notifyDappDisconnect(dappUrl, err) - break - } - } - } - - store.activeSessionsReceived.connect(activeSessionsCallback) - if (!store.getActiveSessions()) { - store.activeSessionsReceived.disconnect(activeSessionsCallback) - // TODO #14754: implement custom dApp notification - } - } function notifyDappDisconnect(dappUrl, err) { const appDomain = StringUtils.extractDomainFromLink(dappUrl) @@ -313,125 +244,6 @@ QObject { } } - Connections { - target: wcSDK - - function onPairResponse(ok) { - if (!ok) { - d.reportPairErrorState(Pairing.errors.unknownError) - } // else waiting for onSessionProposal - } - - function onSessionProposal(sessionProposal) { - const key = sessionProposal.id - d.activeProposals.set(key, sessionProposal) - - const supportedNamespacesStr = DAppsHelpers.buildSupportedNamespacesFromModels( - root.flatNetworks, root.validAccounts, SessionRequest.getSupportedMethods()) - wcSDK.buildApprovedNamespaces(key, sessionProposal.params, JSON.parse(supportedNamespacesStr)) - } - - 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")) { - d.reportPairErrorState(Pairing.errors.unsupportedNetwork) - } else { - d.reportPairErrorState(Pairing.errors.unknownError) - } - return - } - const an = approvedNamespaces.eip155 - if (!(an.accounts) || an.accounts.length === 0 || (!(an.chains) || an.chains.length === 0)) { - d.reportPairErrorState(Pairing.errors.unsupportedNetwork) - return - } - - if (d.acceptedSessionProposal) { - wcSDK.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) - } - } - - 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 - - if (err) { - d.reportPairErrorState(Pairing.errors.unknownError) - return - } - - // TODO #14754: implement custom dApp notification - const app_url = proposal.params.proposer.metadata.url ?? "-" - const app_domain = StringUtils.extractDomainFromLink(app_url) - root.displayToastMessage(qsTr("Connected to %1 via WalletConnect").arg(app_domain), Constants.ephemeralNotificationType.success) - - // Persist session - if(!store.addWalletConnectSession(JSON.stringify(session))) { - console.error("Failed to persist session") - } - - // Notify client - root.approveSessionResult(proposalId, err, session.topic) - - dappsProvider.updateDapps() - } - - 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 = StringUtils.extractDomainFromLink(app_url) - if(err) { - d.reportPairErrorState(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 onSessionDelete(topic, err) { - d.disconnectSessionRequested(topic, err) - } - } - - Component.onCompleted: { - dappsProvider.updateDapps() - } - DAppsRequestHandler { id: requestHandler @@ -449,6 +261,24 @@ QObject { onDisplayToastMessage: (message, type) => { root.displayToastMessage(message, type) } + onPairingResponse: (state) => { + if (state != Pairing.errors.uriOk) { + d.reportPairErrorState(state) + } + } + onConnectDApp: (dappChains, dappUrl, dappName, dappIcon, key) => { + root.connectDApp(dappChains, dappUrl, dappName, dappIcon, key) + } + onApproveSessionResult: (key, error, topic) => { + root.approveSessionResult(key, error, topic) + } + onPairingValidated: (validationState) => { + timeoutTimer.stop() + root.pairingValidated(validationState) + } + onDappDisconnected: (_, dappUrl, err) => { + d.notifyDappDisconnect(dappUrl, err) + } } DAppsListProvider { diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweLifeCycle.qml b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweLifeCycle.qml new file mode 100644 index 0000000000..822b55aa72 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweLifeCycle.qml @@ -0,0 +1,320 @@ +import QtQuick 2.15 + +import StatusQ.Core.Utils 0.1 as SQUtils +import AppLayouts.Wallet.services.dapps 1.0 +import AppLayouts.Wallet.services.dapps.types 1.0 + +import shared.stores 1.0 +import utils 1.0 + +SQUtils.QObject { + id: root + + // The SIWE lifecycle is a state machine that handles the lifecycle of a SIWE request + // Steps: + // 1. Make sure we have session approvals from the user + // 2. Populate the auth payload + // 3. Format the auth message + // 4. Present the formatted auth message to the user + // 5. Sign the auth message + required property WalletConnectSDKBase sdk + // Store object expected with sign and autheticate methods and signals + required property DAppsStore store + // JSON object received from WC + // We're interested in the following properties: + // { + // topic, + // params: { + // requester: { + // metadata: { + // name, + // url, + // icons: [url] + // } + // }, + // authPayload: { + // chains: [chainIds] + // }, + // expiryTimestamp + // }, + // id + // } + required property var request + // Account model with the following roles: + // - address + required property var accountsModel + // Networks model with the following roles: + // - chainId + required property var networksModel + + // Signals the starting of the lifecycle + signal started() + // Signals the end of the lifecycle + signal finished(string error) + // Request session approval from the user + // This request should provide the approved chains, accounts and methods the dApp can use + signal requestSessionApproval(var chains, string dAppUrl, string dAppName, string dAppIcon, var key) + // Register a SessionRequestResolved object to be presented to the user for signing + signal registerSignRequest(var request) + // Unregister the SessionRequestResolved object + signal unregisterSignRequest(var id) + + onFinished: { + if (!request.requestId) { + return + } + sdkConnections.enabled = false + root.unregisterSignRequest(request.requestId) + } + + function start() { + d.start() + } + + // Session approved provides the approved namespaces containing the chains, accounts and methods + function sessionApproved(key, approvedNamespaces) { + if (root.request.id != key) { + return false + } + d.sessionApproved(key, approvedNamespaces) + } + + // Session rejected by the user + function sessionRejected(key) { + if (root.request.id != key) { + return + } + + root.finished("Session rejected") + } + + Connections { + id: sdkConnections + target: root.sdk + enabled: false + + //Third step: format auth message + function onPopulateAuthPayloadResult(id, authPayload, error) { + try { + if (root.request.id != id) { + return + } + + if (error || !authPayload) { + root.finished(error) + return + } + const iss = request.approvedNamespaces.eip155.accounts[0] + request.authPayload = authPayload + sdk.formatAuthMessage(id, authPayload, iss) + } catch (e) { + console.warn("Error in SiweLifeCycle::onPopulateAuthPayloadResult", e) + root.finished(e) + } + } + + //Fourth step: present formatted auth message to user + function onFormatAuthMessageResult(id, authData, error) { + try { + if (root.request.id != id) { + return + } + + if (error || !authData) { + root.finished(error) + return + } + request.preparedData = authData + root.registerSignRequest(request) + } catch (e) { + console.warn("Error in SiweLifeCycle::onFormatAuthMessageResult", e) + root.finished(e) + } + } + + function onAcceptSessionAuthenticateResult(id, result, error) { + if (root.request.id != id) { + return + } + + if (error || !result) { + root.finished(error) + return + } + + root.finished("") + } + } + + // Request object to be used for signing + // The data on this object is filled step by step with the sdk callbacks + // After user has signed the data, the request is sent to the sdk for further processing + SessionRequestWithAuth { + id: request + + property var approvedNamespaces + property var authPayload + property var extractedChainsAndAccounts: approvedNamespaces ? + DAppsHelpers.extractChainsAndAccountsFromApprovedNamespaces(approvedNamespaces) : + { chains: [], accounts: [] } + + sourceId: Constants.DAppConnectors.WalletConnect + store: root.store + + dappUrl: root.request.params.requester.metadata.url + dappName: root.request.params.requester.metadata.name + dappIcon: root.request.params.requester.metadata.icons && root.request.params.requester.metadata.icons.length > 0 ? + root.request.params.requester.metadata.icons[0] : "" + + requestId: root.request.id + accountAddress: extractedChainsAndAccounts.accounts[0] ?? "" + chainId: extractedChainsAndAccounts.chains[0] ?? "" + method: SessionRequest.methods.personalSign.name + event: root.request + topic: root.request.topic + data: "" + preparedData: "" + maxFeesText: "?" + maxFeesEthText: "?" + expirationTimestamp: root.request.params.expiryTimestamp + + function onBuildAuthenticationObjectResult(id, authObject, error) { + if (id != request.requestId) { + return + } + + try { + if (error) { + root.finished(error) + return + } + + sdk.buildAuthObjectResult.disconnect(request.onBuildAuthenticationObjectResult) + if (error) { + request.reject(true) + } + sdk.acceptSessionAuthenticate(id, [authObject]) + } catch (e) { + console.warn("Error in SiweLifeCycle::onBuildAuthenticationObjectResult", e) + root.finished(e) + } + } + + function signedHandler(topic, id, data) { + if (topic != request.topic || id != request.requestId) { + return + } + + try { + root.store.signingResult.disconnect(request.signedHandler) + let hasErrors = (data == "") + if (hasErrors) { + request.reject(true) + } + + sdk.buildAuthObjectResult.connect(request.onBuildAuthenticationObjectResult) + sdk.buildAuthObject(id, request.authPayload, data, request.approvedNamespaces.eip155.accounts[0]) + } catch (e) { + console.warn("Error in SiweLifeCycle::signedHandler", e) + root.finished(e) + } + } + + onRejected: (hasError) => { + try { + sdk.rejectSessionAuthenticate(request.requestId, hasError) + root.finished("Signing rejected") + } catch (e) { + console.warn("Error in SiweLifeCycle::onRejected", e) + root.finished(e) + } + } + + onAuthFailed: () => { + try { + const appDomain = SQUtils.StringUtils.extractDomainFromLink(request.dappUrl) + const methodStr = SessionRequest.methodToUserString(request.method) + if (!methodStr) { + return + } + + root.finished(qsTr("Failed to authenticate %1 from %2").arg(methodStr).arg(appDomain)) + } catch (e) { + console.warn("Error in SiweLifeCycle::onAuthFailed", e) + root.finished(e) + } + } + + onExecute: (password, pin) => { + try { + root.store.signingResult.connect(request.signedHandler) + root.store.signMessage(request.topic, request.requestId, request.accountAddress, request.preparedData, password, pin) + } catch (e) { + console.warn("Error in SiweLifeCycle::onExecute", e) + root.finished(e) + } + } + } + + QtObject { + id: d + + function start() { + if (request.isExpired()) { + console.warn("Error in SiweLifeCycle", "Request expired") + root.finished("Request expired") + return + } + + if (!root.request.id || !root.request.topic) { + console.warn("Error in SiweLifeCycle", "Invalid request") + root.finished("Invalid request") + return + } + sdkConnections.enabled = true + root.started() + // First step: make sure we have session approvals + sdk.getActiveSessions((allSessionsAllProfiles) => { + try { + const sessions = DAppsHelpers.filterActiveSessionsForKnownAccounts(allSessionsAllProfiles, root.accountsModel) + for (const topic in sessions) { + if (topic == root.request.pairingTopic || topic == root.request.topic) { + //TODO: In theory it's possible for a dApp to request SIWE after establishing a session + // This is how MetaMask handles it, but for WC connections it's not clear yet if it's possible + console.warn("Session already exists for request", root.request.id) + root.finished("") + return + } + } + + const key = root.request.id + const chains = root.request.params.authPayload.chains.map(DAppsHelpers.chainIdFromEip155) + const dAppUrl = root.request.params.requester.metadata.url + const dAppName = root.request.params.requester.metadata.name + const dAppIcons = root.request.params.requester.metadata.icons + const dAppIcon = dAppIcons && dAppIcons.length > 0 ? dAppIcons[0] : "" + + root.requestSessionApproval(chains, dAppUrl, dAppName, dAppIcon, key) + } catch (e) { + console.warn("Error in SiweLifeCycle", e) + root.finished(e) + } + }) + } + + //Second step: populate auth payload + function sessionApproved(key, approvedNamespaces) { + try { + request.approvedNamespaces = approvedNamespaces + const supportedChains = approvedNamespaces.eip155.chains + const supportedMethods = approvedNamespaces.eip155.methods + sdk.populateAuthPayload(request.requestId, root.request.params.authPayload, supportedChains, supportedMethods) + return true + } catch (e) { + console.warn("Error in SiweLifeCycle::sessionApproved", e) + root.finished(e) + } + return false + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweRequestPlugin.qml b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweRequestPlugin.qml new file mode 100644 index 0000000000..dc02681ed7 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweRequestPlugin.qml @@ -0,0 +1,142 @@ +import QtQuick 2.15 + +import AppLayouts.Wallet.services.dapps 1.0 +import StatusQ.Core.Utils 0.1 as SQUtils + +import shared.stores 1.0 +import utils 1.0 + +/* + SiweRequestPlugin is a plugin that listens for siwe requests and manages the lifecycle of the request. +*/ +SQUtils.QObject { + id: root + + required property WalletConnectSDKBase sdk + // Store object expected with sign and autheticate methods and signals + required property DAppsStore store + // Account model with the following roles: + // - address + required property var accountsModel + // Networks model with the following roles: + // - chainId + required property var networksModel + + // Trigger a connection request to the dApp + // Expected `connectionApproved` to be called with the key and the approved namespaces + signal connectDApp(var chains, string dAppUrl, string dAppName, string dAppIcon, var key) + // Register a SessionRequestResolved object to be presented to the user for signing + signal registerSignRequest(var request) + // Unregister the SessionRequestResolved object + signal unregisterSignRequest(var requestId) + // Signal that the request was successful + signal siweSuccessful(var requestId, var topicId) + // Signal that the request failed + signal siweFailed(var requestId, string error, var topicId) + + // return true if the request was found and approved + function connectionApproved(key, approvedNamespaces) { + const siweLifeCycle = d.getSiweLifeCycle(key) + if (!siweLifeCycle) { + return false + } + + siweLifeCycle.sessionApproved(key, approvedNamespaces) + return true + } + + // return true if the request was found and rejected + function connectionRejected(key) { + const siweLifeCycle = d.getSiweLifeCycle(key) + if (!siweLifeCycle) { + return false + } + + siweLifeCycle.sessionRejected(key) + return true + } + + Instantiator { + id: requestLifecycle + model: d.requests + // When a new request is added, we create a new SiweLifeCycle object that starts working on it + delegate: SiweLifeCycle { + required property var model + required property int index + + sdk: root.sdk + store: root.store + accountsModel: root.accountsModel + networksModel: root.networksModel + request: model + onFinished: (error) => { + if (error) { + root.siweFailed(request.id, error, request.topic) + Qt.callLater(() => { + d.requests.remove(index, 1) + }) + return + } + + sdk.getActiveSessions((allSessions) => { + for (const topic in allSessions) { + if (allSessions[topic].pairingTopic != request.topic) { + continue + } + root.siweSuccessful(request.id, topic) + return + } + // No new session was created + // Should not happen + root.siweSuccessful(request.id, "") + }) + } + onRequestSessionApproval: (chains, dAppUrl, dAppName, dAppIcon, key) => { + root.connectDApp(chains, dAppUrl, dAppName, dAppIcon, key) + } + onRegisterSignRequest: (request) => { + root.registerSignRequest(request) + } + onUnregisterSignRequest: (id) => { + root.unregisterSignRequest(id) + } + } + + onObjectAdded: (_, obj) => { + obj.start() + } + } + + Connections { + target: sdk + + function onSessionAuthenticateRequest(sessionData) { + if (!sessionData || !sessionData.id) { + console.warn("Error in SiweRequestPlugin: Invalid session authenticate request", sessionData) + return + } + + if (d.getSiweLifeCycle(sessionData.id)) { + console.warn("Error in SiweRequestPlugin: Session request already exists", sessionData.id) + return + } + d.requests.append(sessionData) + } + } + + QtObject { + id: d + property ListModel requests: ListModel {} + + function getSiweLifeCycle(requestId) { + for (let i = 0; i < requestLifecycle.count; i++) { + const siweLifeCycle = requestLifecycle.objectAt(i) + if (siweLifeCycle.request.id == requestId) { + return siweLifeCycle + } + } + + return null + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir b/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir new file mode 100644 index 0000000000..62f3bf0b52 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir @@ -0,0 +1,2 @@ +SiweRequestPlugin 1.0 SiweRequestPlugin.qml +SiweLifeCycle 1.0 SiweLifeCycle.qml \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml index 7de116cdba..d1a604d1e3 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml @@ -31,9 +31,9 @@ QObject { // Data prepared for display in a human readable format required property var preparedData - readonly property alias dappName: d.dappName - readonly property alias dappUrl: d.dappUrl - readonly property alias dappIcon: d.dappIcon + property alias dappName: d.dappName + property alias dappUrl: d.dappUrl + property alias dappIcon: d.dappIcon /// extra data resolved from wallet property string maxFeesText: "" diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestWithAuth.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestWithAuth.qml new file mode 100644 index 0000000000..bb311869ab --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestWithAuth.qml @@ -0,0 +1,59 @@ +import QtQuick 2.15 + +import shared.stores 1.0 + +SessionRequestResolved { + id: root + + required property DAppsStore store + + // Signal to execute the request. Emitted after successful authentication + // password and pin are the user's input for the authentication + signal execute(string password, string pin) + // Signal to reject the request. Emitted when the request is expired or rejected by the user + // hasError is true if the request was rejected due to an error + signal rejected(bool hasError) + // Signal when the authentication flow fails + signal authFailed() + signal accepted() + + function accept() { + if (root.isExpired()) { + console.warn("Error: request expired") + root.reject(true) + return + } + storeConnections.enabled = true + store.authenticateUser(root.topic, root.requestId, root.accountAddress, "") + root.accepted() + } + + function reject(hasError) { + storeConnections.enabled = false + root.rejected(hasError) + } + + Connections { + id: storeConnections + enabled: false + target: root.store + + function onUserAuthenticated(topic, id, password, pin, _) { + if (id == root.requestId && topic == root.topic) { + if (root.isExpired()) { + console.warn("Error: request expired") + root.reject(true) + return + } + root.execute(password, pin) + } + } + + function onUserAuthenticationFailed(topic, id) { + if (id === root.requestId && topic === root.topic) { + storeConnections.enabled = false + root.authFailed() + } + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir b/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir index 93a107d050..9860129909 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir @@ -1,4 +1,5 @@ SessionRequestResolved 1.0 SessionRequestResolved.qml +SessionRequestWithAuth 1.0 SessionRequestWithAuth.qml SessionRequestsModel 1.0 SessionRequestsModel.qml singleton SessionRequest 1.0 SessionRequest.qml singleton Pairing 1.0 Pairing.qml \ No newline at end of file