feat(dapps) add DAppsService component and ConnectDAppModal

Implement the initial Pairing user workflow and disconnect option for
the first session.

Also

- rename pairing modal accordingly (`PairWCModal.qml`) to make room for the proper
`ConnectDAppModal.qml`
- basic tests for service helpers
- update storybook to reflect the new user workflows

Closes #14607
This commit is contained in:
Stefan 2024-05-06 22:22:43 +02:00 committed by Stefan Dunca
parent 4771f0d77f
commit ee72ec7aee
25 changed files with 2812 additions and 103 deletions

View File

@ -56,8 +56,8 @@ SplitView {
store: ProfileSectionStore { store: ProfileSectionStore {
property WalletStore walletStore: WalletStore { property WalletStore walletStore: WalletStore {
accountSensitiveSettings: mockData.accountSettings property var accountSensitiveSettings: mockData.accountSettings
dappList: dappsModel property var dappList: dappsModel
function disconnect(dappName) { function disconnect(dappName) {
for (let i = 0; i < dappsModel.count; i++) { for (let i = 0; i < dappsModel.count; i++) {

View File

@ -0,0 +1,126 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQml 2.15
import Qt.labs.settings 1.0
import QtTest 1.15
import StatusQ.Core 0.1
import StatusQ.Core.Utils 0.1
import StatusQ.Controls 0.1
import StatusQ.Components 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Popups.Dialog 0.1
import Models 1.0
import Storybook 1.0
import shared.popups.walletconnect 1.0
import SortFilterProxyModel 0.2
import AppLayouts.Wallet.panels 1.0
import utils 1.0
import shared.stores 1.0
Item {
id: root
function openModal() {
modal.openWithFilter([1, 42161], JSON.parse(`{
"metadata": {
"description": "React App for WalletConnect",
"icons": [
"https://avatars.githubusercontent.com/u/37784886"
],
"name": "React App",
"url": "https://react-app.walletconnect.com",
"verifyUrl": "https://verify.walletconnect.com"
},
"publicKey": "300a6a1df4cb0cd73eb652f11845f35a318541eb18ab369860be85c0c2ada54a"
}`))
if (pairedCheckbox.checked) {
pairedResultTimer.restart()
}
}
// qml Splitter
SplitView {
anchors.fill: parent
ColumnLayout {
SplitView.fillWidth: true
Component.onCompleted: root.openModal()
StatusButton {
id: openButton
Layout.alignment: Qt.AlignHCenter
Layout.margins: 20
text: "Open ConnectDAppModal"
onClicked: root.openModal()
}
ConnectDAppModal {
id: modal
anchors.centerIn: parent
spacing: 8
accounts: WalletAccountsModel{
}
flatNetworks: SortFilterProxyModel {
sourceModel: NetworksModel.flatNetworks
filters: ValueFilter { roleName: "isTest"; value: false; }
}
}
ColumnLayout {}
}
ColumnLayout {
id: optionsSpace
CheckBox {
id: pairedCheckbox
text: "Report Paired"
checked: true
}
CheckBox {
id: pairedStatusCheckbox
text: "Paired Successful"
checked: true
}
Item { Layout.fillHeight: true }
}
}
Timer {
id: pairedResultTimer
interval: 1000
running: false
repeat: false
onTriggered: {
if (pairedCheckbox.checked) {
if (pairedStatusCheckbox.checked) {
modal.pairSuccessful(null)
} else {
modal.pairFailed(null, "Pairing failed")
}
}
}
}
}
// category: Wallet

View File

@ -11,6 +11,7 @@ import StatusQ.Controls 0.1
import StatusQ.Components 0.1 import StatusQ.Components 0.1
import StatusQ.Core.Theme 0.1 import StatusQ.Core.Theme 0.1
import StatusQ.Popups.Dialog 0.1 import StatusQ.Popups.Dialog 0.1
import StatusQ.Core.Utils 0.1 as SQUtils
import Models 1.0 import Models 1.0
import Storybook 1.0 import Storybook 1.0
@ -21,6 +22,7 @@ import AppLayouts.Wallet.services.dapps 1.0
import SortFilterProxyModel 0.2 import SortFilterProxyModel 0.2
import AppLayouts.Wallet.panels 1.0 import AppLayouts.Wallet.panels 1.0
import AppLayouts.Profile.stores 1.0
import utils 1.0 import utils 1.0
import shared.stores 1.0 import shared.stores 1.0
@ -49,6 +51,8 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
spacing: 8 spacing: 8
wcService: walletConnectService
} }
} }
ColumnLayout {} ColumnLayout {}
@ -62,11 +66,20 @@ Item {
Text { Text {
id: projectIdText id: projectIdText
readonly property string projectId: SystemUtils.getEnvVar("WALLET_CONNECT_PROJECT_ID") readonly property string projectId: SystemUtils.getEnvVar("WALLET_CONNECT_PROJECT_ID")
text: projectId.substring(0, 3) + "..." + projectId.substring(projectId.length - 3) text: SQUtils.Utils.elideText(projectId, 3)
font.bold: true font.bold: true
} }
} }
CheckBox {
text: "Testnet Mode"
checked: settings.testNetworks
onCheckedChanged: {
settings.testNetworks = checked
}
}
// spacer // spacer
ColumnLayout {} ColumnLayout {}
@ -87,7 +100,7 @@ Item {
CheckBox { CheckBox {
id: openPairCheckBox
text: "Open Pair" text: "Open Pair"
checked: settings.openPair checked: settings.openPair
onCheckedChanged: { onCheckedChanged: {
@ -96,10 +109,11 @@ Item {
d.startPairing() d.startPairing()
} }
} }
Connections { Connections {
target: dappsWorkflow target: dappsWorkflow
// Open Pairing workflow if selected in the side bar // If Open Pair workflow if selected in the side bar
function onDAppsListReady() { function onDAppsListReady() {
if (!d.startPairingWorkflowActive) if (!d.startPairingWorkflowActive)
return return
@ -113,7 +127,7 @@ Item {
} }
} }
function onConnectDappReady() { function onPairWCReady() {
if (!d.startPairingWorkflowActive) if (!d.startPairingWorkflowActive)
return return
@ -121,25 +135,52 @@ Item {
let items = InspectionUtils.findVisualsByTypeName(dappsWorkflow, "StatusBaseInput") let items = InspectionUtils.findVisualsByTypeName(dappsWorkflow, "StatusBaseInput")
if (items.length === 1) { if (items.length === 1) {
items[0].text = pairUriInput.text items[0].text = pairUriInput.text
clickDoneIfSDKReady()
} }
} }
d.startPairingWorkflowActive = false }
function clickDoneIfSDKReady() {
if (!d.startPairingWorkflowActive) {
return
}
let modals = InspectionUtils.findVisualsByTypeName(dappsWorkflow, "PairWCModal")
if (modals.length === 1) {
let buttons = InspectionUtils.findVisualsByTypeName(modals[0].footer, "StatusButton")
if (buttons.length === 1 && walletConnectService.wcSDK.sdkReady) {
d.startPairingWorkflowActive = false
buttons[0].clicked()
return
}
}
Backpressure.debounce(dappsWorkflow, 250, clickDoneIfSDKReady)()
} }
} }
} }
} }
} }
DAppsStore { WalletConnectService {
wCSDK: WalletConnectSDK { id: walletConnectService
wcSDK: WalletConnectSDK {
active: true active: true
projectId: projectIdText.projectId projectId: projectIdText.projectId
}
onSessionRequestEvent: (details) => { dappsStore: DAppsStore {
// TODO #14556 }
console.debug(`@dd onSessionRequestEvent: ${JSON.stringify(details)}`)
walletStore: WalletStore {
property var flatNetworks: SortFilterProxyModel {
sourceModel: NetworksModel.flatNetworks
filters: ValueFilter { roleName: "isTest"; value: settings.testNetworks; }
} }
property var accounts: WalletAccountsModel{}
} }
} }
@ -150,7 +191,7 @@ Item {
property bool startPairingWorkflowActive: false property bool startPairingWorkflowActive: false
function startPairing() { function startPairing() {
startPairingWorkflowActive = true d.startPairingWorkflowActive = true
if(root.visible) { if(root.visible) {
dappsWorkflow.clicked() dappsWorkflow.clicked()
} }
@ -168,6 +209,7 @@ Item {
property bool openPair: false property bool openPair: false
property string pairUri: "" property string pairUri: ""
property bool testNetworks: false
} }
} }

View File

@ -98,6 +98,10 @@ SplitView {
} }
return prefChains return prefChains
} }
function addressWasShown(account) {
return true
}
} }
} }
} }

View File

@ -7,44 +7,128 @@ import QtQuick.Controls 2.15
import Storybook 1.0 import Storybook 1.0
import AppLayouts.Wallet.controls 1.0 //import AppLayouts.Wallet.panels 1.0
import AppLayouts.Wallet.services.dapps 1.0
import QtQml.Models 2.15
Item { Item {
id: root id: root
width: 600 width: 600
height: 400 height: 400
Component { // TODO: mock WalletConnectSDK
id: componentUnderTest // Component {
DAppsWorkflow { // id: componentUnderTest
} // DAppsWorkflow {
} // }
// }
// TestCase {
// name: "DAppsWorkflow"
// when: windowShown
// property DAppsWorkflow controlUnderTest: null
// function init() {
// controlUnderTest = createTemporaryObject(componentUnderTest, root)
// }
// function test_ClickToOpenAndClosePopup() {
// verify(!!controlUnderTest)
// waitForRendering(controlUnderTest)
// mouseClick(controlUnderTest, Qt.LeftButton)
// waitForRendering(controlUnderTest)
// let popup = findChild(controlUnderTest, "dappsPopup")
// verify(!!popup)
// verify(popup.opened)
// mouseClick(Overlay.overlay, Qt.LeftButton)
// waitForRendering(controlUnderTest)
// verify(!popup.opened)
// }
// }
TestCase { TestCase {
name: "DAppsWorkflow" name: "ServiceHelpers"
when: windowShown
property DAppsWorkflow controlUnderTest: null function test_extractChainsAndAccountsFromApprovedNamespaces() {
let res = Helpers.extractChainsAndAccountsFromApprovedNamespaces(JSON.parse(`{
function init() { "eip155": {
controlUnderTest = createTemporaryObject(componentUnderTest, root) "accounts": [
"eip155:1:0x1",
"eip155:1:0x2",
"eip155:2:0x1",
"eip155:2:0x2"
],
"chains": [
"eip155:1",
"eip155:2"
],
"events": [
"accountsChanged",
"chainChanged"
],
"methods": [
"eth_sendTransaction",
"personal_sign"
]
}
}`))
verify(res.chains.length === 2)
verify(res.accounts.length === 2)
verify(res.chains[0] === 1)
verify(res.chains[1] === 2)
verify(res.accounts[0] === "0x1")
verify(res.accounts[1] === "0x2")
} }
function test_ClickToOpenAndClosePopup() { readonly property ListModel chainsModel: ListModel {
verify(!!controlUnderTest) ListElement { chainId: 1 }
waitForRendering(controlUnderTest) ListElement { chainId: 2 }
}
mouseClick(controlUnderTest, Qt.LeftButton) readonly property ListModel accountsModel: ListModel {
waitForRendering(controlUnderTest) ListElement { address: "0x1" }
ListElement { address: "0x2" }
}
let popup = findChild(controlUnderTest, "dappsPopup") function test_buildSupportedNamespacesFromModels() {
verify(!!popup) let resStr = Helpers.buildSupportedNamespacesFromModels(chainsModel, accountsModel)
verify(popup.opened) let jsonObj = JSON.parse(resStr)
verify(jsonObj.hasOwnProperty("eip155"))
let eip155 = jsonObj.eip155
mouseClick(Overlay.overlay, Qt.LeftButton) verify(eip155.hasOwnProperty("chains"))
waitForRendering(controlUnderTest) let chains = eip155.chains
verify(chains.length === 2)
verify(chains[0] === "eip155:1")
verify(chains[1] === "eip155:2")
verify(!popup.opened) verify(eip155.hasOwnProperty("accounts"))
let accounts = eip155.accounts
verify(accounts.length === 4)
for (let chainI = 0; chainI < chainsModel.count; chainI++) {
for (let accountI = 0; accountI < chainsModel.count; accountI++) {
var found = false
for (let entry of accounts) {
if(entry === `eip155:${chainsModel.get(chainI).chainId}:${accountsModel.get(accountI).address}`) {
found = true
break
}
}
verify(found, `found ${accountsModel.get(accountI).address} for chain ${chainsModel.get(chainI).chainId}`)
}
}
verify(eip155.hasOwnProperty("methods"))
verify(eip155.methods.length > 0)
verify(eip155.hasOwnProperty("events"))
verify(eip155.events.length > 0)
} }
} }
} }

View File

@ -76,12 +76,12 @@ QtObject {
isEnabled: true, isEnabled: true,
}, },
{ {
chainId: 5, chainId: 11155111,
chainName: "Goerli", chainName: "Sepolia Mainnet",
blockExplorerUrl: "https://goerli.etherscan.io/", blockExplorerUrl: "https://sepolia.etherscan.io/",
iconUrl: "network/Network=Testnet", iconUrl: "network/Network=Ethereum",
chainColor: "#939BA1", chainColor: "#627EEA",
shortName: "goEth", shortName: "eth",
nativeCurrencyName: "Ether", nativeCurrencyName: "Ether",
nativeCurrencySymbol: "ETH", nativeCurrencySymbol: "ETH",
nativeCurrencyDecimals: 18, nativeCurrencyDecimals: 18,

View File

@ -1,6 +1,4 @@
import QtQuick 2.14 import QtQuick 2.14
QtObject { QtObject {
property var accountSensitiveSettings: ({})
property var dappList: []
} }

View File

@ -1,9 +1,3 @@
import QtQuick 2.15 import QtQuick 2.15
import AppLayouts.Wallet.services.dapps 1.0 QtObject {}
QtObject {
id: root
required property WalletConnectSDK wCSDK
}

View File

@ -5,12 +5,17 @@ import QtQuick.Layouts 1.15
import AppLayouts.Wallet.controls 1.0 import AppLayouts.Wallet.controls 1.0
import shared.popups.walletconnect 1.0 import shared.popups.walletconnect 1.0
import AppLayouts.Wallet.services.dapps 1.0
import shared.stores 1.0
ConnectedDappsButton { ConnectedDappsButton {
id: root id: root
required property WalletConnectService wcService
signal dAppsListReady() signal dAppsListReady()
signal connectDappReady() signal pairWCReady()
onClicked: { onClicked: {
dappsListLoader.active = true dappsListLoader.active = true
@ -19,23 +24,23 @@ ConnectedDappsButton {
highlighted: dappsListLoader.active highlighted: dappsListLoader.active
Loader { Loader {
id: connectDappLoader id: pairWCLoader
active: false active: false
onLoaded: { onLoaded: {
item.open() item.open()
root.connectDappReady() root.pairWCReady()
} }
sourceComponent: ConnectDappModal { sourceComponent: PairWCModal {
visible: true visible: true
onClosed: connectDappLoader.active = false onClosed: pairWCLoader.active = false
onPair: (uri) => { onPair: (uri) => {
this.close() root.wcService.pair(uri)
console.debug(`TODO(#14556): ConnectionRequestDappModal with ${uri}`) this.isPairing = true
} }
} }
} }
@ -53,8 +58,8 @@ ConnectedDappsButton {
sourceComponent: DAppsListPopup { sourceComponent: DAppsListPopup {
visible: true visible: true
onConnectDapp: { onPairWCDapp: {
connectDappLoader.active = true pairWCLoader.active = true
this.close() this.close()
} }
onOpened: { onOpened: {
@ -64,4 +69,72 @@ ConnectedDappsButton {
onClosed: dappsListLoader.active = false onClosed: dappsListLoader.active = false
} }
} }
Loader {
id: connectDappLoader
active: false
onLoaded: item.openWithFilter(dappChains, sessionProposal.params.proposer)
property var dappChains: []
property var sessionProposal: null
property var availableNamespaces: null
property var sessionTopic: null
sourceComponent: ConnectDAppModal {
visible: true
onClosed: connectDappLoader.active = false
accounts: wcService.validAccounts
flatNetworks: wcService.flatNetworks
onConnect: {
root.wcService.approvePairSession(sessionProposal, dappChains, selectedAccount)
}
onDecline: {
connectDappLoader.active = false
root.wcService.rejectPairSession(sessionProposal.id)
}
onDisconnect: {
connectDappLoader.active = false
root.wcService.disconnectDapp(sessionTopic)
}
}
}
Connections {
target: root.wcService
function onConnectDApp(dappChains, sessionProposal, availableNamespaces) {
connectDappLoader.dappChains = dappChains
connectDappLoader.sessionProposal = sessionProposal
connectDappLoader.availableNamespaces = availableNamespaces
connectDappLoader.sessionTopic = null
if (pairWCLoader.item) {
pairWCLoader.item.close()
}
connectDappLoader.active = true
}
function onApproveSessionResult(session, err) {
connectDappLoader.dappChains = []
connectDappLoader.sessionProposal = null
connectDappLoader.availableNamespaces = null
connectDappLoader.sessionTopic = session.topic
let modal = connectDappLoader.item
if (!!modal) {
if (err) {
modal.pairFailed(session, err)
} else {
modal.pairSuccessful(session)
}
}
}
}
} }

View File

@ -80,6 +80,9 @@ Item {
spacing: 8 spacing: 8
visible: !root.walletStore.showSavedAddresses && Global.featureFlags.dappsEnabled visible: !root.walletStore.showSavedAddresses && Global.featureFlags.dappsEnabled
enabled: !!Global.walletConnectService
wcService: Global.walletConnectService
} }
StatusButton { StatusButton {

View File

@ -27,9 +27,11 @@ Item {
signal statusChanged(string message) signal statusChanged(string message)
signal sdkInit(bool success, var result) signal sdkInit(bool success, var result)
signal pairResponse(bool success)
signal sessionProposal(var sessionProposal) signal sessionProposal(var sessionProposal)
signal sessionProposalExpired() signal sessionProposalExpired()
signal approveSessionResult(var session, string error) signal buildApprovedNamespacesResult(var session, string error)
signal approveSessionResult(var approvedNamespaces, string error)
signal rejectSessionResult(string error) signal rejectSessionResult(string error)
signal sessionRequestEvent(var sessionRequest) signal sessionRequestEvent(var sessionRequest)
signal sessionRequestUserAnswerResult(bool accept, string error) signal sessionRequestUserAnswerResult(bool accept, string error)
@ -40,6 +42,8 @@ Item {
signal sessionDelete(var topic, string error) signal sessionDelete(var topic, string error)
/// Generates \c pairResponse signal and expects to receive
/// a \c sessionProposal signal with the sessionProposal object
function pair(pairLink) { function pair(pairLink) {
wcCalls.pair(pairLink) wcCalls.pair(pairLink)
} }
@ -64,6 +68,10 @@ Item {
wcCalls.ping(topic) wcCalls.ping(topic)
} }
function buildApprovedNamespaces(params, supportedNamespaces) {
wcCalls.buildApprovedNamespaces(params, supportedNamespaces)
}
function approveSession(sessionProposal, supportedNamespaces) { function approveSession(sessionProposal, supportedNamespaces) {
wcCalls.approveSession(sessionProposal, supportedNamespaces) wcCalls.approveSession(sessionProposal, supportedNamespaces)
} }
@ -227,6 +235,21 @@ Item {
) )
} }
function buildApprovedNamespaces(params, supportedNamespaces) {
console.debug(`WC WalletConnectSDK.wcCall.buildApprovedNamespaces; params: ${JSON.stringify(params)}, supportedNamespaces: ${JSON.stringify(supportedNamespaces)}`)
d.engine.runJavaScript(`
wc.buildApprovedNamespaces(${JSON.stringify(params)}, ${JSON.stringify(supportedNamespaces)})
.then((approvedNamespaces) => {
wc.statusObject.onBuildApprovedNamespacesResponse(approvedNamespaces, "")
})
.catch((e) => {
wc.statusObject.onBuildApprovedNamespacesResponse("", e.message)
})
`
)
}
function approveSession(sessionProposal, supportedNamespaces) { function approveSession(sessionProposal, supportedNamespaces) {
console.debug(`WC WalletConnectSDK.wcCall.approveSession; sessionProposal: ${JSON.stringify(sessionProposal)}, supportedNamespaces: ${JSON.stringify(supportedNamespaces)}`) console.debug(`WC WalletConnectSDK.wcCall.approveSession; sessionProposal: ${JSON.stringify(sessionProposal)}, supportedNamespaces: ${JSON.stringify(supportedNamespaces)}`)
@ -413,6 +436,7 @@ Item {
function onPairResponse(error) { function onPairResponse(error) {
console.debug(`WC WalletConnectSDK.onPairResponse; error: ${error}`) console.debug(`WC WalletConnectSDK.onPairResponse; error: ${error}`)
root.pairResponse(error == "")
} }
function onPingResponse(error) { function onPingResponse(error) {
@ -430,6 +454,11 @@ Item {
d.resetPairingsModel() d.resetPairingsModel()
} }
function onBuildApprovedNamespacesResponse(approvedNamespaces, error) {
console.debug(`WC WalletConnectSDK.onBuildApprovedNamespacesResponse; approvedNamespaces: ${approvedNamespaces ? JSON.stringify(approvedNamespaces, null, 2) : "-"}, error: ${error}`)
root.buildApprovedNamespacesResult(approvedNamespaces, error)
}
function onApproveSessionResponse(session, error) { function onApproveSessionResponse(session, error) {
console.debug(`WC WalletConnectSDK.onApproveSessionResponse; sessionTopic: ${JSON.stringify(session, null, 2)}, error: ${error}`) console.debug(`WC WalletConnectSDK.onApproveSessionResponse; sessionTopic: ${JSON.stringify(session, null, 2)}, error: ${error}`)
d.resetPairingsModel() d.resetPairingsModel()

View File

@ -0,0 +1,102 @@
import QtQuick 2.15
import AppLayouts.Wallet.services.dapps 1.0
import AppLayouts.Profile.stores 1.0
import shared.stores 1.0
import shared.popups.walletconnect 1.0
import SortFilterProxyModel 0.2
import utils 1.0
QtObject {
id: root
required property WalletConnectSDK wcSDK
required property DAppsStore dappsStore
required property WalletStore walletStore
readonly property var validAccounts: SortFilterProxyModel {
sourceModel: walletStore.accounts
filters: ValueFilter {
roleName: "walletType"
value: Constants.watchWalletType
inverted: true
}
}
readonly property var flatNetworks: walletStore.flatNetworks
function pair(uri) {
_d.acceptedSessionProposal = null
wcSDK.pair(uri)
}
function approvePairSession(sessionProposal, approvedChainIds, approvedAccount) {
_d.acceptedSessionProposal = sessionProposal
let approvedNamespaces = JSON.parse(Helpers.buildSupportedNamespaces(approvedChainIds, [approvedAccount.address]))
wcSDK.buildApprovedNamespaces(sessionProposal.params, approvedNamespaces)
}
function rejectPairSession(id) {
wcSDK.rejectSession(id)
}
function disconnectDapp(sessionTopic) {
wcSDK.disconnectSession(sessionTopic)
}
signal connectDApp(var dappChains, var sessionProposal, var approvedNamespaces)
signal approveSessionResult(var session, var error)
readonly property Connections sdkConnections: Connections {
target: wcSDK
function onSessionProposal(sessionProposal) {
_d.currentSessionProposal = sessionProposal
let supportedNamespacesStr = Helpers.buildSupportedNamespacesFromModels(root.flatNetworks, root.validAccounts)
wcSDK.buildApprovedNamespaces(sessionProposal.params, JSON.parse(supportedNamespacesStr))
}
function onBuildApprovedNamespacesResult(approvedNamespaces, error) {
if(error) {
// TODO: error reporting
return
}
if (_d.acceptedSessionProposal) {
wcSDK.approveSession(_d.acceptedSessionProposal, approvedNamespaces)
} else {
let res = Helpers.extractChainsAndAccountsFromApprovedNamespaces(approvedNamespaces)
root.connectDApp(res.chains, _d.currentSessionProposal, approvedNamespaces)
}
}
function onApproveSessionResult(session, err) {
root.approveSessionResult(session, err)
}
function onRejectSessionResult(err) {
let app_url = _d.currentSessionProposal ? _d.currentSessionProposal.params.proposer.url : "-"
if(err) {
console.debug(`TODO #14556: show a notification "Failed to reject connection request for ${app_url}"`)
} else {
console.debug(`TODO #14556: show a notification "Connection request for ${app_url} was rejected"`)
}
}
function onSessionDelete(topic, error) {
let app_url = _d.currentSessionProposal ? _d.currentSessionProposal.params.proposer.url : "-"
if(error) {
console.debug(`TODO #14556: show a notification "Failed to disconnect from ${app_url}"`)
} else {
console.debug(`TODO #14556: show a notification "Disconnected from ${app_url}"`)
}
}
}
readonly property QtObject _d: QtObject {
property var currentSessionProposal: null
property var acceptedSessionProposal: null
}
}

View File

@ -0,0 +1,39 @@
.import StatusQ.Core.Utils 0.1 as SQUtils
function extractChainsAndAccountsFromApprovedNamespaces(approvedNamespaces) {
const eip155Data = approvedNamespaces.eip155;
const chains = eip155Data.chains.map(chain => parseInt(chain.split(':').pop().trim(), 10));
const accountSet = new Set(
eip155Data.accounts.map(account => account.split(':').pop().trim())
);
const uniqueAccounts = Array.from(accountSet);
return { chains, accounts: uniqueAccounts };
}
function buildSupportedNamespacesFromModels(chainsModel, accountsModel) {
var chainIds = []
var addresses = []
for (let i = 0; i < chainsModel.count; i++) {
let entry = SQUtils.ModelUtils.get(chainsModel, i)
chainIds.push(parseInt(entry.chainId))
}
for (let i = 0; i < accountsModel.count; i++) {
let entry = SQUtils.ModelUtils.get(accountsModel, i)
addresses.push(entry.address)
}
return buildSupportedNamespaces(chainIds, addresses)
}
function buildSupportedNamespaces(chainIds, addresses) {
var eipChainIds = []
var eipAddresses = []
for (let i = 0; i < chainIds.length; i++) {
let chainId = chainIds[i]
eipChainIds.push(`"eip155:${chainId}"`)
for (let i = 0; i < addresses.length; i++) {
eipAddresses.push(`"eip155:${chainId}:${addresses[i]}"`)
}
}
return `{
"eip155":{"chains": [${eipChainIds.join(',')}],"methods": ["eth_sendTransaction", "personal_sign"],"events": ["accountsChanged", "chainChanged"],"accounts": [${eipAddresses.join(',')}]}}`
}

View File

@ -1 +1,4 @@
WalletConnectSDK 1.0 WalletConnectSDK.qml WalletConnectSDK 1.0 WalletConnectSDK.qml
WalletConnectService 1.0 WalletConnectService.qml
Helpers 1.0 helpers.js

View File

@ -17,7 +17,7 @@ Install dependencies steps by executing commands in this directory:
- or to update to the latest run `ncu -u; npm install` in here - or to update to the latest run `ncu -u; npm install` in here
- run `npm install -g npm-check-updates` for `ncu` command - run `npm install -g npm-check-updates` for `ncu` command
- these commands will also create or update a `package-lock.json` file and populate the `node_modules` directory - these commands will also create or update a `package-lock.json` file and populate the `node_modules` directory
- update the [`bundle.js`](./dist/main.js) file by running `npm run build` - update the [`bundle.js`](./generated/bundle.js) file by running `npm run build`
- the result will be embedded with the app and loaded by [`WalletConnectSDK.qml`](../WalletConnectSDK.qml) component - the result will be embedded with the app and loaded by [`WalletConnectSDK.qml`](../WalletConnectSDK.qml) component
- add the newly generated files to index `git add --update .` to include in the commit - add the newly generated files to index `git add --update .` to include in the commit

File diff suppressed because one or more lines are too long

View File

@ -129,18 +129,18 @@ window.wc = {
await window.wc.web3wallet.engine.signClient.ping({ topic }); await window.wc.web3wallet.engine.signClient.ping({ topic });
}, },
approveSession: async function (sessionProposal, supportedNamespaces) { buildApprovedNamespaces: async function (params, supportedNamespaces) {
return buildApprovedNamespaces({
proposal: params,
supportedNamespaces: supportedNamespaces,
});
},
approveSession: async function (sessionProposal, approvedNamespaces) {
const { id, params } = sessionProposal; const { id, params } = sessionProposal;
const { relays } = params const { relays } = params
const approvedNamespaces = buildApprovedNamespaces(
{
proposal: params,
supportedNamespaces: supportedNamespaces,
}
);
return await window.wc.web3wallet.approveSession( return await window.wc.web3wallet.approveSession(
{ {
id, id,

View File

@ -78,18 +78,6 @@ Item {
walletAssetStore: appMain.walletAssetsStore walletAssetStore: appMain.walletAssetsStore
tokensStore: appMain.tokensStore tokensStore: appMain.tokensStore
} }
readonly property DAppsStore dappsStore: DAppsStore {
wCSDK: WalletConnectSDK {
active: WalletStore.RootStore.walletSectionInst.walletReady
projectId: WalletStore.RootStore.appSettings.walletConnectProjectID
onSessionRequestEvent: (details) => {
// TODO #14556
console.debug(`@dd onSessionRequestEvent: ${JSON.stringify(details)}`)
}
}
}
// set from main.qml // set from main.qml
property var sysPalette property var sysPalette
@ -2040,4 +2028,27 @@ Item {
onClosed: userAgreementLoader.active = false onClosed: userAgreementLoader.active = false
} }
} }
Loader {
id: walletConnectServiceLoader
active: Global.featureFlags.dappsEnabled
sourceComponent: WalletConnectService {
id: walletConnectService
wcSDK: WalletConnectSDK {
active: WalletStore.RootStore.walletSectionInst.walletReady
projectId: WalletStore.RootStore.appSettings.walletConnectProjectID
}
dappsStore: DAppsStore {
}
walletStore: appMain.rootStore.profileSectionStore.walletStore
Component.onCompleted: {
Global.walletConnectService = walletConnectService
}
}
}
} }

View File

@ -0,0 +1,380 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQml.Models 2.14
import SortFilterProxyModel 0.2
import StatusQ 0.1
import StatusQ.Core 0.1
import StatusQ.Popups.Dialog 0.1
import StatusQ.Controls 0.1
import StatusQ.Components 0.1
import StatusQ.Core.Theme 0.1
// TODO extract the components to StatusQ
import shared.popups.send.controls 1.0
import AppLayouts.Wallet.controls 1.0
import utils 1.0
StatusDialog {
id: root
width: 480
implicitHeight: d.connectionStatus === root.notConnectedStatus ? 633 : 681
required property var accounts
required property var flatNetworks
readonly property alias selectedAccount: d.selectedAccount
readonly property int notConnectedStatus: 0
readonly property int connectionSuccessfulStatus: 1
readonly property int connectionFailedStatus: 2
function openWithFilter(dappChains, proposer) {
d.connectionStatus = root.notConnectedStatus
d.afterTwoSecondsFromStatus = false
let m = proposer.metadata
dappCard.name = m.name
dappCard.url = m.url
if(m.icons.length > 0) {
dappCard.icon = m.icons[0]
}
d.dappChains.clear()
for (let i = 0; i < dappChains.length; i++) {
// Convert to int
d.dappChains.append({ chainId: parseInt(dappChains[i]) })
}
root.open()
}
function pairSuccessful(session) {
d.connectionStatus = root.connectionSuccessfulStatus
closeAndRetryTimer.start()
}
function pairFailed(session, err) {
d.connectionStatus = root.connectionFailedStatus
closeAndRetryTimer.start()
}
Timer {
id: closeAndRetryTimer
interval: 2000
running: false
repeat: false
onTriggered: {
d.afterTwoSecondsFromStatus = true
}
}
signal connect()
signal decline()
signal disconnect()
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
title: qsTr("Connection request")
padding: 20
contentItem: ColumnLayout {
spacing: 20
clip: true
DAppCard {
id: dappCard
Layout.alignment: Qt.AlignHCenter
Layout.leftMargin: 12
Layout.rightMargin: Layout.leftMargin
Layout.topMargin: 20
Layout.bottomMargin: Layout.topMargin
}
ContextCard {
Layout.fillWidth: true
}
PermissionsCard {
Layout.fillWidth: true
Layout.leftMargin: 12
Layout.rightMargin: Layout.leftMargin
Layout.topMargin: 20
Layout.bottomMargin: Layout.topMargin
}
}
footer: StatusDialogFooter {
id: footer
rightButtons: ObjectModel {
StatusButton {
height: 44
text: qsTr("Decline")
visible: d.connectionStatus === root.notConnectedStatus
onClicked: root.decline()
}
StatusButton {
height: 44
text: qsTr("Disconnect")
visible: d.connectionStatus === root.connectionSuccessfulStatus
type: StatusBaseButton.Type.Danger
onClicked: root.disconnect()
}
StatusButton {
height: 44
text: d.connectionStatus === root.notConnectedStatus
? qsTr("Connect")
: qsTr("Close")
onClicked: {
if (d.connectionStatus === root.notConnectedStatus)
root.connect()
else
root.close()
}
}
}
}
component ContextCard: Rectangle {
id: contextCard
implicitWidth: contextLayout.implicitWidth
implicitHeight: contextLayout.implicitHeight
radius: 8
// TODO: the color matched the design color (grey4); It is also matching the intention or we should add some another color to the theme? (e.g. sectionBorder)?
border.color: Theme.palette.baseColor2
border.width: 1
color: "transparent"
ColumnLayout {
id: contextLayout
anchors.fill: parent
RowLayout {
Layout.margins: 16
StatusBaseText {
text: qsTr("Connect with")
Layout.fillWidth: true
}
// TODO: have a reusable component for this
AccountsModalHeader {
id: accountsDropdown
Layout.preferredWidth: 204
control.enabled: d.connectionStatus === root.notConnectedStatus && count > 1
model: d.accountsProxy
onCountChanged: {
if (count > 0) {
selectedAccount = d.accountsProxy.get(0)
}
}
selectedAccount: d.accountsProxy.get(0)
onSelectedAccountChanged: d.selectedAccount = selectedAccount
onSelectedIndexChanged: {
d.selectedAccount = model.get(selectedIndex)
selectedAccount = d.selectedAccount
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: contextCard.border.color
}
RowLayout {
Layout.margins: 16
StatusBaseText {
text: qsTr("On")
Layout.fillWidth: true
}
// TODO: replace with a specialized network selection control
NetworkFilter {
Layout.preferredWidth: accountsDropdown.Layout.preferredWidth
flatNetworks: d.filteredChains
showAllSelectedText: false
showCheckboxes: false
enabled: d.connectionStatus === root.notConnectedStatus
}
}
}
}
component DAppCard: ColumnLayout {
property alias name: appNameText.text
property alias url: appUrlText.text
property alias icon: iconDisplay.asset.source
// TODO: this doesn't work as expected, the icon is not displayed properly
// TODO: set a fallback icon for when the provided icon is not available
StatusRoundIcon {
id: iconDisplay
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 16
width: 72
height: 72
asset.width: width
asset.height: height
asset.color: "transparent"
asset.bgColor: "transparent"
}
StatusBaseText {
id: appNameText
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 4
font.bold: true
font.pixelSize: 17
}
// TODO replace with the proper URL control
StatusLinkText {
id: appUrlText
Layout.alignment: Qt.AlignHCenter
font.pixelSize: 15
}
Rectangle {
Layout.preferredWidth: pairingStatusLayout.implicitWidth + 32
Layout.preferredHeight: pairingStatusLayout.implicitHeight + 14
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 16
visible: d.connectionStatus !== root.notConnectedStatus
color: d.connectionStatus === root.connectionSuccessfulStatus
? d.afterTwoSecondsFromStatus
? Theme.palette.successColor2
: Theme.palette.successColor3
: d.afterTwoSecondsFromStatus
? "transparent"
: Theme.palette.dangerColor3
border.color: d.connectionStatus === root.connectionSuccessfulStatus
? Theme.palette.successColor2
: Theme.palette.dangerColor2
border.width: 1
radius: height / 2
RowLayout {
id: pairingStatusLayout
anchors.centerIn: parent
spacing: 8
Rectangle {
width: 6
height: 6
radius: width / 2
visible: d.connectionStatus === root.connectionSuccessfulStatus
color: Theme.palette.successColor1
}
StatusIcon {
Layout.preferredWidth: 16
Layout.preferredHeight: 16
visible: d.connectionStatus !== root.connectionSuccessfulStatus
color: Theme.palette.dangerColor1
icon: "warning"
}
StatusBaseText {
text: {
if (d.connectionStatus === root.connectionSuccessfulStatus)
return qsTr("Connected. You can now go back to the dApp.")
else if (d.connectionStatus === root.connectionFailedStatus)
return qsTr("Error connecting to dApp. Close and try again")
return ""
}
font.pixelSize: 12
color: d.connectionStatus === root.connectionSuccessfulStatus ? Theme.palette.directColor1 : Theme.palette.dangerColor1
}
}
}
}
component PermissionsCard: ColumnLayout {
spacing: 8
StatusBaseText {
text: qsTr("Uniswap Interface will be able to:")
font.pixelSize: 13
color: Theme.palette.baseColor1
}
StatusBaseText {
text: qsTr("Check your account balance and activity")
font.pixelSize: 13
}
StatusBaseText {
text: qsTr("Request transactions and message signing")
font.pixelSize: 13
}
}
QtObject {
id: d
property SortFilterProxyModel accountsProxy: SortFilterProxyModel {
sourceModel: root.accounts
sorters: RoleSorter { roleName: "position"; sortOrder: Qt.AscendingOrder }
}
property var selectedAccount: accountsProxy.count > 0 ? accountsProxy.get(0) : null
readonly property var filteredChains: LeftJoinModel {
leftModel: d.dappChains
rightModel: root.flatNetworks
joinRole: "chainId"
}
readonly property var dappChains: ListModel {}
property int connectionStatus: notConnectedStatus
property bool afterTwoSecondsFromStatus: false
}
}

View File

@ -15,7 +15,7 @@ Popup {
property int menuWidth: 312 property int menuWidth: 312
signal connectDapp() signal pairWCDapp()
contentWidth: root.menuWidth contentWidth: root.menuWidth
contentHeight: list.height contentHeight: list.height
@ -60,7 +60,7 @@ Popup {
text: qsTr("Connect a dApp via WalletConnect") text: qsTr("Connect a dApp via WalletConnect")
onClicked: { onClicked: {
root.connectDapp() root.pairWCDapp()
} }
} }
} }

View File

@ -9,16 +9,18 @@ import StatusQ.Controls 0.1
import utils 1.0 import utils 1.0
import "ConnectDappModal" import "PairWCModal"
StatusDialog { StatusDialog {
id: root id: root
signal pair(string uri)
width: 480 width: 480
implicitHeight: 633 implicitHeight: 633
property bool isPairing: false
signal pair(string uri)
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
title: qsTr("Connect a dApp via WalletConnect") title: qsTr("Connect a dApp via WalletConnect")
@ -32,10 +34,12 @@ StatusDialog {
WCUriInput { WCUriInput {
id: uriInput id: uriInput
onTextChanged: root.isPairing = false
} }
// Spacer // Spacer
ColumnLayout {} Item { Layout.fillHeight: true }
StatusLinkText { StatusLinkText {
text: qsTr("How to copy the dApp URI") text: qsTr("How to copy the dApp URI")
@ -58,7 +62,7 @@ StatusDialog {
height: 44 height: 44
text: qsTr("Done") text: qsTr("Done")
enabled: uriInput.valid && uriInput.text.length > 0 enabled: uriInput.valid && !root.isPairing && uriInput.text.length > 0
onClicked: root.pair(uriInput.text) onClicked: root.pair(uriInput.text)
} }

View File

@ -1,2 +1,3 @@
ConnectDappModal 1.0 ConnectDappModal.qml PairWCModal 1.0 PairWCModal.qml
DAppsListPopup 1.0 DAppsListPopup.qml DAppsListPopup 1.0 DAppsListPopup.qml
ConnectDAppModal 1.0 ConnectDAppModal.qml

View File

@ -1,11 +1,3 @@
import QtQuick 2.15 import QtQuick 2.15
import AppLayouts.Wallet.services.dapps 1.0 QtObject {}
QtObject {
id: root
required property WalletConnectSDK wCSDK
// Here we will have business logic calls and expose connections history models
}

View File

@ -14,6 +14,9 @@ QtObject {
property var userProfile property var userProfile
property bool appIsReady: false property bool appIsReady: false
// use the generic var as type to break the cyclic dependency
property var walletConnectService: null
// avoid lookup of context property in QML // avoid lookup of context property in QML
readonly property var featureFlags: featureFlagsRootContextProperty readonly property var featureFlags: featureFlagsRootContextProperty