feat(wallet) Wallet Connect Pair integration

Implement Controller to forward requests between status-go and SDK
implementation in QML.

Other changes:

- Source Wallet Connect projectId from env vars
- Mock controller in storybook

Updates #12551
This commit is contained in:
Stefan 2023-10-27 19:18:24 +03:00 committed by Stefan Dunca
parent d4e15fe932
commit bcf5b64298
20 changed files with 603 additions and 185 deletions

View File

@ -14,6 +14,8 @@ import ./overview/module as overview_module
import ./send/module as send_module
import ./activity/controller as activityc
import ./wallet_connect/controller as wcc
import app/modules/shared_modules/collectibles/controller as collectiblesc
import app/modules/shared_modules/collectible_details/controller as collectible_detailsc
@ -81,6 +83,8 @@ type
# instance to be used in temporary, short-lived, workflows (e.g. send popup)
tmpActivityController: activityc.Controller
wcController: wcc.Controller
## Forward declaration
proc onUpdatedKeypairsOperability*(self: Module, updatedKeypairs: seq[KeypairDto])
proc onLocalPairingStatusUpdate*(self: Module, data: LocalPairingStatus)
@ -137,7 +141,9 @@ proc newModule*(
result.collectibleDetailsController = collectible_detailsc.newController(int32(backend_collectibles.CollectiblesRequestID.WalletAccount), networkService, events)
result.filter = initFilter(result.controller)
result.view = newView(result, result.activityController, result.tmpActivityController, result.collectiblesController, result.collectibleDetailsController)
result.wcController = wcc.newController(events)
result.view = newView(result, result.activityController, result.tmpActivityController, result.collectiblesController, result.collectibleDetailsController, result.wcController)
method delete*(self: Module) =
self.accountsModule.delete
@ -152,6 +158,7 @@ method delete*(self: Module) =
self.tmpActivityController.delete
self.collectiblesController.delete
self.collectibleDetailsController.delete
self.wcController.delete
if not self.addAccountModule.isNil:
self.addAccountModule.delete

View File

@ -5,6 +5,7 @@ import app/modules/shared_modules/collectibles/controller as collectiblesc
import app/modules/shared_modules/collectible_details/controller as collectible_detailsc
import ./io_interface
import ../../shared_models/currency_amount
import ./wallet_connect/controller as wcc
QtObject:
type
@ -21,6 +22,7 @@ QtObject:
collectibleDetailsController: collectible_detailsc.Controller
isNonArchivalNode: bool
keypairOperabilityForObservedAccount: string
wcController: wcc.Controller
proc setup(self: View) =
self.QObject.setup
@ -28,13 +30,15 @@ QtObject:
proc delete*(self: View) =
self.QObject.delete
proc newView*(delegate: io_interface.AccessInterface, activityController: activityc.Controller, tmpActivityController: activityc.Controller, collectiblesController: collectiblesc.Controller, collectibleDetailsController: collectible_detailsc.Controller): View =
proc newView*(delegate: io_interface.AccessInterface, activityController: activityc.Controller, tmpActivityController: activityc.Controller, collectiblesController: collectiblesc.Controller, collectibleDetailsController: collectible_detailsc.Controller, wcController: wcc.Controller): View =
new(result, delete)
result.delegate = delegate
result.activityController = activityController
result.tmpActivityController = tmpActivityController
result.collectiblesController = collectiblesController
result.collectibleDetailsController = collectibleDetailsController
result.wcController = wcController
result.setup()
proc load*(self: View) =
@ -203,3 +207,8 @@ QtObject:
proc destroyKeypairImportPopup*(self: View) {.signal.}
proc emitDestroyKeypairImportPopup*(self: View) =
self.destroyKeypairImportPopup()
proc getWalletConnectController(self: View): QVariant {.slot.} =
return newQVariant(self.wcController)
QtProperty[QVariant] walletConnectController:
read = getWalletConnectController

View File

@ -0,0 +1,52 @@
import NimQml, logging, json
import backend/wallet_connect as backend
import app/core/eventemitter
import app/core/signals/types
import constants
QtObject:
type
Controller* = ref object of QObject
events: EventEmitter
proc setup(self: Controller) =
self.QObject.setup
proc delete*(self: Controller) =
self.QObject.delete
proc newController*(events: EventEmitter): Controller =
new(result, delete)
result.events = events
result.setup()
# Register for wallet events
result.events.on(SignalType.Wallet.event, proc(e: Args) =
# TODO #12434: async processing
discard
)
# supportedNamespaces is a Namespace as defined in status-go: services/wallet/walletconnect/walletconnect.go
proc proposeUserPair*(self: Controller, sessionProposalJson: string, supportedNamespacesJson: string) {.signal.}
proc pairSessionProposal(self: Controller, sessionProposalJson: string) {.slot.} =
let ok = backend.pair(sessionProposalJson, proc (res: JsonNode) =
let sessionProposalJson = if res.hasKey("sessionProposal"): $res["sessionProposal"] else: ""
let supportedNamespacesJson = if res.hasKey("supportedNamespaces"): $res["supportedNamespaces"] else: ""
self.proposeUserPair(sessionProposalJson, supportedNamespacesJson)
)
if not ok:
error "Failed to pair session"
proc getProjectId*(self: Controller): string {.slot.} =
return constants.WALLET_CONNECT_PROJECT_ID
QtProperty[string] projectId:
read = getProjectId

View File

@ -0,0 +1,26 @@
import options
import json
import core, response_type
from gen import rpc
import backend
# Declared in services/wallet/walletconnect/walletconnect.go
#const eventWCTODO*: string = "wallet-wc-todo"
# Declared in services/wallet/walletconnect/walletconnect.go
const ErrorChainsNotSupported*: string = "chains not supported"
rpc(wCPairSessionProposal, "wallet"):
sessionProposalJson: string
# TODO #12434: async answer
proc pair*(sessionProposalJson: string, callback: proc(response: JsonNode): void): bool =
try:
let response = wCPairSessionProposal(sessionProposalJson)
if response.error == nil and response.result != nil:
callback(response.result)
return response.error == nil
except Exception as e:
echo "@dd wCPairSessionProposal response: ", e.msg
return false

View File

@ -54,3 +54,4 @@ let
ALCHEMY_OPTIMISM_MAINNET_TOKEN_RESOLVED* = desktopConfig.alchemyOptimismMainnetToken
ALCHEMY_OPTIMISM_GOERLI_TOKEN_RESOLVED* = desktopConfig.alchemyOptimismGoerliToken
OPENSEA_API_KEY_RESOLVED* = desktopConfig.openseaApiKey
WALLET_CONNECT_PROJECT_ID* = BUILD_WALLET_CONNECT_PROJECT_ID

View File

@ -20,6 +20,7 @@ const BASE_NAME_ALCHEMY_ARBITRUM_MAINNET_TOKEN = "ALCHEMY_ARBITRUM_MAINNET_TOKEN
const BASE_NAME_ALCHEMY_ARBITRUM_GOERLI_TOKEN = "ALCHEMY_ARBITRUM_GOERLI_TOKEN"
const BASE_NAME_ALCHEMY_OPTIMISM_MAINNET_TOKEN = "ALCHEMY_OPTIMISM_MAINNET_TOKEN"
const BASE_NAME_ALCHEMY_OPTIMISM_GOERLI_TOKEN = "ALCHEMY_OPTIMISM_GOERLI_TOKEN"
const BASE_NAME_WALLET_CONNECT_PROJECT_ID = "WALLET_CONNECT_PROJECT_ID"
################################################################################
@ -36,6 +37,9 @@ const BUILD_ALCHEMY_ARBITRUM_MAINNET_TOKEN = getEnv(BUILD_TIME_PREFIX & BASE_NAM
const BUILD_ALCHEMY_ARBITRUM_GOERLI_TOKEN = getEnv(BUILD_TIME_PREFIX & BASE_NAME_ALCHEMY_ARBITRUM_GOERLI_TOKEN)
const BUILD_ALCHEMY_OPTIMISM_MAINNET_TOKEN = getEnv(BUILD_TIME_PREFIX & BASE_NAME_ALCHEMY_OPTIMISM_MAINNET_TOKEN)
const BUILD_ALCHEMY_OPTIMISM_GOERLI_TOKEN = getEnv(BUILD_TIME_PREFIX & BASE_NAME_ALCHEMY_OPTIMISM_GOERLI_TOKEN)
const
WALLET_CONNECT_STATUS_PROJECT_ID = "87815d72a81d739d2a7ce15c2cfdefb3"
BUILD_WALLET_CONNECT_PROJECT_ID = getEnv(BUILD_TIME_PREFIX & BASE_NAME_WALLET_CONNECT_PROJECT_ID, WALLET_CONNECT_STATUS_PROJECT_ID)
################################################################################
# Run time evaluated variables

View File

@ -19,6 +19,8 @@ import SortFilterProxyModel 0.2
import utils 1.0
import nim 1.0
Item {
id: root
@ -29,11 +31,17 @@ Item {
WalletConnect {
id: walletConnect
SplitView.preferredWidth: 400
SplitView.fillWidth: true
projectId: SystemUtils.getEnvVar("WALLET_CONNECT_PROJECT_ID")
backgroundColor: Theme.palette.statusAppLayout.backgroundColor
controller: WalletConnectController {
pairSessionProposal: function(sessionProposalJson) {
proposeUserPair(sessionProposalJson, `{"eip155":{"methods":["eth_sendTransaction","personal_sign"],"chains":["eip155:5"],"events":["accountsChanged","chainChanged"],"accounts":["eip155:5:0x53780d79E83876dAA21beB8AFa87fd64CC29990b","eip155:5:0xBd54A96c0Ae19a220C8E1234f54c940DFAB34639","eip155:5:0x5D7905390b77A937Ae8c444aA8BF7Fa9a6A7DBA0"]}}`)
}
projectId: SystemUtils.getEnvVar("STATUS_BUILD_WALLET_CONNECT_PROJECT_ID")
}
clip: true
}
@ -45,7 +53,8 @@ Item {
Text { text: "projectId" }
Text {
text: walletConnect.projectId.substring(0, 3) + "..." + walletConnect.projectId.substring(walletConnect.projectId.length - 3)
readonly property string projectId: walletConnect.controller.projectId
text: projectId.substring(0, 3) + "..." + projectId.substring(projectId.length - 3)
font.bold: true
}
}

View File

@ -0,0 +1,15 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
// Stub for Controller QObject defined in src/app/modules/main/wallet_section/wallet_connect/controller.nim
Item {
id: root
signal proposeUserPair(string sessionProposalJson, string supportedNamespacesJson)
// function pairSessionProposal(/*string*/ sessionProposalJson)
required property var pairSessionProposal
required property string projectId
}

View File

@ -0,0 +1 @@
WalletConnectController 1.0 WalletConnectController.qml

View File

@ -20,7 +20,7 @@ func loginToAccount(hashedPassword, userFolder, nodeConfigJson string) error {
}
accountsJson := statusgo.OpenAccounts(absUserFolder)
accounts := make([]multiaccounts.Account, 0)
err = getApiResponse(accountsJson, &accounts)
err = getCAPIResponse(accountsJson, &accounts)
if err != nil {
return err
}
@ -33,7 +33,7 @@ func loginToAccount(hashedPassword, userFolder, nodeConfigJson string) error {
keystorePath := filepath.Join(filepath.Join(absUserFolder, "keystore/"), account.KeyUID)
initKeystoreJson := statusgo.InitKeystore(keystorePath)
apiResponse := statusgo.APIResponse{}
err = getApiResponse(initKeystoreJson, &apiResponse)
err = getCAPIResponse(initKeystoreJson, &apiResponse)
if err != nil {
return err
}
@ -44,7 +44,7 @@ func loginToAccount(hashedPassword, userFolder, nodeConfigJson string) error {
return err
}
loginJson := statusgo.LoginWithConfig(string(accountJson), hashedPassword, nodeConfigJson)
err = getApiResponse(loginJson, &apiResponse)
err = getCAPIResponse(loginJson, &apiResponse)
if err != nil {
return err
}
@ -64,7 +64,7 @@ type jsonrpcRequest struct {
Params json.RawMessage `json:"params,omitempty"`
}
func callPrivateMethod(method string, params interface{}) string {
func callPrivateMethod(method string, params []interface{}) string {
var paramsJson json.RawMessage
var err error
if params != nil {
@ -131,7 +131,7 @@ func processConfigArgs() (config *Config, nodeConfigJson string, userFolder stri
return
}
func getApiResponse[T any](responseJson string, res T) error {
func getCAPIResponse[T any](responseJson string, res T) error {
apiResponse := statusgo.APIResponse{}
err := json.Unmarshal([]byte(responseJson), &apiResponse)
if err == nil {
@ -154,3 +154,48 @@ func getApiResponse[T any](responseJson string, res T) error {
return nil
}
type jsonrpcSuccessfulResponse struct {
jsonrpcMessage
Result json.RawMessage `json:"result"`
}
type jsonrpcErrorResponse struct {
jsonrpcMessage
Error jsonError `json:"error"`
}
// jsonError represents Error message for JSON-RPC responses.
type jsonError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func getRPCAPIResponse[T any](responseJson string, res T) error {
errApiResponse := jsonrpcErrorResponse{}
err := json.Unmarshal([]byte(responseJson), &errApiResponse)
if err == nil && errApiResponse.Error.Code != 0 {
return fmt.Errorf("API error: %#v", errApiResponse.Error)
}
apiResponse := jsonrpcSuccessfulResponse{}
err = json.Unmarshal([]byte(responseJson), &apiResponse)
if err != nil {
return fmt.Errorf("failed to unmarshal jsonrpcSuccessfulResponse: %w", err)
}
typeOfT := reflect.TypeOf(res)
kindOfT := typeOfT.Kind()
// Check for valid types: pointer, slice, map
if kindOfT != reflect.Ptr && kindOfT != reflect.Slice && kindOfT != reflect.Map {
return fmt.Errorf("type T must be a pointer, slice, or map")
}
if err := json.Unmarshal(apiResponse.Result, &res); err != nil {
return fmt.Errorf("failed to unmarshal data: %w", err)
}
return nil
}

View File

@ -13,7 +13,8 @@
/>
<div id="buttonRow">
<button id="pairButton" disabled>Pair</button>
<!-- TODO DEV <button id="pairButton" disabled>Pair</button>-->
<button id="pairButton">Pair</button>
<button id="authButton" disabled>Auth</button>
<button id="acceptButton" style="display: none">Accept</button>
<button id="rejectButton" style="display: none">Reject</button>
@ -75,64 +76,27 @@
pairButton.addEventListener("click", function () {
setStatus("Pairing...");
try {
// TODO DEV: remove harcode
wc.pair(pairLinkInput.value)
.then((sessionProposal) => {
//let sessionProposal = JSON.parse(`{"id":1698771618724119,"params":{"id":1698771618724119,"pairingTopic":"4c36da43ac0d351336663276de6b5e2182f42068b7021e3cd1a460d9d7077e20","expiry":1698771923,"requiredNamespaces":{"eip155":{"methods":["eth_sendTransaction","personal_sign"],"chains":["eip155:5"],"events":["chainChanged","accountsChanged"]}},"optionalNamespaces":{"eip155":{"methods":["eth_signTransaction","eth_sign","eth_signTypedData","eth_signTypedData_v4"],"chains":["eip155:5"],"events":[]}},"relays":[{"protocol":"irn"}],"proposer":{"publicKey":"5d648503f2ec0e5a263578c347e490dcacb4e4359f59d66f12905bb91b9f182d","metadata":{"description":"React App for WalletConnect","url":"https://react-app.walletconnect.com","icons":["https://avatars.githubusercontent.com/u/37784886"],"name":"React App","verifyUrl":"https://verify.walletconnect.com"}}},"verifyContext":{"verified":{"verifyUrl":"https://verify.walletconnect.com","validation":"UNKNOWN","origin":"https://react-app.walletconnect.com"}}}`);
setStatus(`Wait user pair`);
setDetails(
`Pair ID: ${sessionProposal.id} ; Topic: ${sessionProposal.params.pairingTopic}`
);
acceptButton.addEventListener("click", function () {
window.wc.approveSession(sessionProposal).then(
() => {
goEcho(`Session ${sessionProposal.id} approved`);
setStatus(
`Session ${sessionProposal.id} approved`
);
acceptButton.style.display = "none";
rejectButton.style.display = "none";
},
(err) => {
goEcho(
`Session ${sessionProposal.id} approve error: ${err.message}`
);
setStatus(
`Session ${sessionProposal.id} approve error: ${err.message}`
);
}
);
});
rejectButton.addEventListener("click", function () {
try {
window.wc.rejectSession(sessionProposal.id).then(
() => {
goEcho(`Session ${sessionProposal.id} rejected`);
setStatus(
`Session ${sessionProposal.id} rejected`
);
acceptButton.style.display = "none";
rejectButton.style.display = "none";
},
(err) => {
window
.pairSessionProposal(JSON.stringify(sessionProposal))
.then((success) => {
if (!success) {
goEcho(
`Session ${sessionProposal.id} reject error`
);
setStatus(
`Session ${sessionProposal.id} reject error`
);
}
);
} catch (err) {
goEcho(
`Session ${sessionProposal.id} reject error: ${err.message}`
);
setStatus(
`Session ${sessionProposal.id} reject error: ${err.message}`
`GO.pairSessionProposal call failed ${sessionProposal.id}`
);
setGoStatus(`GO.pairSessionProposal failed`, "red");
return;
}
});
acceptButton.style.display = "inline";
rejectButton.style.display = "inline";
// Waiting for "proposeUserPair" event
})
.catch((error) => {
goEcho(`Pairing error ${JSON.stringify(error)}`);
@ -149,9 +113,17 @@
window.auth();
});
window.wc.registerForSessionRequest(event => {
window.wc.registerForSessionRequest((event) => {
setStatus(`Session topic ${event.topic}`);
})
window.sessionRequest(event).then((success) => {
if (!success) {
goEcho(`Session request status-go call failed ${event.topic}`);
setGoStatus(`Session ${event.id} rejected`, "purple");
return;
}
// Waiting for userApproveSession event
});
});
}
function goEcho(message) {
@ -188,8 +160,65 @@
setGoStatus("ready");
statusGoReady();
break;
case "tokensAvailable":
setDetails(`${JSON.stringify(event.payload)}\n`);
case "proposeUserPair":
setGoStatus("Session proposed");
setDetails(JSON.stringify(event.payload.supportedNamespaces));
let sessionProposal = event.payload.sessionProposal;
acceptButton.addEventListener("click", function () {
try {
window.wc
.approveSession(
sessionProposal,
event.payload.supportedNamespaces
)
.then(
() => {
goEcho(`Session ${sessionProposal.id} approved`);
setStatus(`Session ${sessionProposal.id} approved`);
acceptButton.style.display = "none";
rejectButton.style.display = "none";
},
(err) => {
goEcho(
`Session ${sessionProposal.id} approve error: ${err}`
);
setStatus(
`Session ${sessionProposal.id} approve error: ${err}`
);
}
);
} catch (err) {
goEcho(`WTF error: ${err.message}`);
setStatus(`WTF error: ${err.message}`);
}
});
rejectButton.addEventListener("click", function () {
try {
window.wc.rejectSession(sessionProposal.id).then(
() => {
goEcho(`Session ${sessionProposal.id} rejected`);
setStatus(`Session ${sessionProposal.id} rejected`);
acceptButton.style.display = "none";
rejectButton.style.display = "none";
},
(err) => {
goEcho(`Session ${sessionProposal.id} reject error`);
setStatus(`Session ${sessionProposal.id} reject error`);
}
);
} catch (err) {
goEcho(
`Session ${sessionProposal.id} reject error: ${err.message}`
);
setStatus(
`Session ${sessionProposal.id} reject error: ${err.message}`
);
}
});
acceptButton.style.display = "inline";
rejectButton.style.display = "inline";
break;
default:
await new Promise((resolve) => setTimeout(resolve, 100));

View File

@ -6,9 +6,11 @@ import (
"net/http"
"os"
"github.com/ethereum/go-ethereum/log"
webview "github.com/webview/webview_go"
statusgo "github.com/status-im/status-go/mobile"
wc "github.com/status-im/status-go/services/wallet/walletconnect"
"github.com/status-im/status-go/services/wallet/walletevent"
"github.com/status-im/status-go/signal"
)
@ -23,7 +25,7 @@ type Configuration struct {
type GoEvent struct {
Name string `json:"name"`
Payload string `json:"payload"`
Payload interface{} `json:"payload"`
}
var eventQueue chan GoEvent = make(chan GoEvent, 10000)
@ -44,10 +46,25 @@ func signalHandler(jsonEvent string) {
if envelope.Type == signal.EventNodeReady {
eventQueue <- GoEvent{Name: "nodeReady", Payload: ""}
} else if envelope.Type == "wallet" {
// parse envelope.Event to json
walletEvent := walletevent.Event{}
err := json.Unmarshal([]byte(jsonEvent), &walletEvent)
if err != nil {
fmt.Println("@dd Error parsing the wallet event: ", err)
return
}
// TODO: continue from here
if walletEvent.Type == "WalletConnectProposeUserPair" {
eventQueue <- GoEvent{Name: "proposeUserPair", Payload: walletEvent.Message}
}
}
}
func main() {
// Setup status-go logger
log.Root().SetHandler(log.StdoutHandler)
signal.SetDefaultNodeNotificationHandler(signalHandler)
config, nodeConfigJson, userFolder, err := processConfigArgs()
if err != nil {
@ -64,10 +81,26 @@ func main() {
w := webview.New(true)
defer w.Destroy()
w.SetTitle("WC status-go test")
w.SetSize(480, 320, webview.HintNone)
w.SetSize(620, 480, webview.HintNone)
w.Bind("pairSessionProposal", func(sessionProposalJson string) bool {
sessionReqRes := callPrivateMethod("wallet_wCPairSessionProposal", []interface{}{sessionProposalJson})
var apiResponse wc.PairSessionResponse
err = getRPCAPIResponse(sessionReqRes, &apiResponse)
if err != nil {
log.Error("Error parsing the API response", "error", err)
return false
}
go func() {
eventQueue <- GoEvent{Name: "proposeUserPair", Payload: apiResponse}
}()
return true
})
w.Bind("getConfiguration", func() Configuration {
projectID := os.Getenv("WALLET_CONNECT_PROJECT_ID")
projectID := os.Getenv("STATUS_BUILD_WALLET_CONNECT_PROJECT_ID")
return Configuration{ProjectId: projectID}
})
@ -88,11 +121,13 @@ func main() {
// Start a local server to serve the files
http.HandleFunc("/bundle.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
http.ServeFile(w, r, "../../../ui/app/AppLayouts/Wallet/views/walletconnect/sdk/generated/bundle.js")
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "index.html")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
http.ServeFile(w, r, "./index.html")
})
go http.ListenAndServe(":8080", nil)

View File

@ -24,6 +24,11 @@ import "../controls"
import "../popups"
import "../panels"
// TODO: remove DEV import
import AppLayouts.Wallet.stores 1.0 as WalletStores
import AppLayouts.Wallet.views.walletconnect 1.0
// TODO end
SettingsContentBase {
id: root
@ -151,6 +156,55 @@ SettingsContentBase {
}
}
StatusSettingsLineButton {
anchors.leftMargin: 0
anchors.rightMargin: 0
text: qsTr("Debug Wallet Connect")
visible: root.advancedStore.isDebugEnabled
onClicked: {
wcLoader.active = true
}
Component {
id: wcDialogComponent
StatusDialog {
id: wcDialog
onOpenedChanged: {
if (!opened) {
wcLoader.active = false
}
}
WalletConnect {
SplitView.preferredWidth: 400
SplitView.preferredHeight: 600
backgroundColor: wcDialog.backgroundColor
controller: WalletStores.RootStore.walletConnectController
}
clip: true
}
}
Loader {
id: wcLoader
active: false
onStatusChanged: {
if (status === Loader.Ready) {
wcLoader.item.open()
}
}
sourceComponent: wcDialogComponent
}
}
Separator {
width: parent.width
}

View File

@ -27,12 +27,14 @@ QtObject {
property var accountSensitiveSettings: localAccountSensitiveSettings
property bool hideSignPhraseModal: accountSensitiveSettings.hideSignPhraseModal
// "walletSection" is a context property slow to lookup, so we cache it here
property var walletSectionInst: walletSection
property var totalCurrencyBalance: walletSection.totalCurrencyBalance
property var activityController: walletSection.activityController
property var tmpActivityController: walletSection.tmpActivityController
property string signingPhrase: walletSection.signingPhrase
property string mnemonicBackedUp: walletSection.isMnemonicBackedUp
property var totalCurrencyBalance: walletSectionInst.totalCurrencyBalance
property var activityController: walletSectionInst.activityController
property var tmpActivityController: walletSectionInst.tmpActivityController
property string signingPhrase: walletSectionInst.signingPhrase
property string mnemonicBackedUp: walletSectionInst.isMnemonicBackedUp
property var walletConnectController: walletSectionInst.walletConnectController
property CollectiblesStore collectiblesStore: CollectiblesStore {}

View File

@ -0,0 +1,17 @@
import QtQuick 2.15
QtObject {
enum RequestCodes {
SdkInitSuccess,
SdkInitError,
PairSuccess,
PairError,
ApprovePairSuccess,
ApprovePairError,
RejectPairSuccess,
RejectPairError
}
}

View File

@ -13,11 +13,10 @@ Item {
implicitWidth: Math.min(mainLayout.implicitWidth, 400)
implicitHeight: Math.min(mainLayout.implicitHeight, 700)
required property string projectId
required property color backgroundColor
property alias optionalSdkPath: sdkView.optionalSdkPath
// wallet_connect.Controller \see wallet_section/wallet_connect/controller.nim
required property var controller
ColumnLayout {
id: mainLayout
@ -45,7 +44,7 @@ Item {
statusText.text = "Pairing..."
sdkView.pair(pairLinkInput.text)
}
enabled: pairLinkInput.text.length > 0 && sdkView.state === sdkView.disconnectedState
enabled: pairLinkInput.text.length > 0 && sdkView.sdkReady
}
StatusButton {
@ -54,30 +53,50 @@ Item {
statusText.text = "Authenticating..."
sdkView.auth()
}
enabled: false && pairLinkInput.text.length > 0 && sdkView.state === sdkView.disconnectedState
enabled: false && pairLinkInput.text.length > 0 && sdkView.sdkReady
}
StatusButton {
text: "Accept"
onClicked: {
sdkView.acceptPairing()
sdkView.approvePairSession(d.sessionProposal, d.supportedNamespaces)
}
visible: sdkView.state === sdkView.waitingPairState
visible: root.state === d.waitingPairState
}
StatusButton {
text: "Reject"
onClicked: {
sdkView.rejectPairing()
sdkView.rejectPairSession(d.sessionProposal.id)
}
visible: sdkView.state === sdkView.waitingPairState
visible: root.state === d.waitingPairState
}
}
RowLayout {
ColumnLayout {
StatusBaseText {
id: statusText
text: "-"
}
Flickable {
Layout.fillWidth: true
Layout.preferredHeight: 200
Layout.maximumHeight: 400
contentWidth: detailsText.width
contentHeight: detailsText.height
StatusBaseText {
id: detailsText
text: ""
visible: text.length > 0
color: "#FF00FF"
}
ScrollBar.vertical: ScrollBar {}
clip: true
}
}
// TODO: DEBUG JS Loading in DMG
@ -126,16 +145,104 @@ Item {
WalletConnectSDK {
id: sdkView
projectId: root.projectId
projectId: controller.projectId
backgroundColor: root.backgroundColor
Layout.fillWidth: true
// Note that a too smaller height might cause the webview to generate rendering errors
Layout.preferredHeight: 10
onSdkInit: function(success, info) {
d.setDetailsText(info)
if (success) {
d.setStatusText("Ready to pair or auth")
root.state = d.sdkReadyState
} else {
d.setStatusText("SDK Error", "red")
root.state = ""
}
}
onPairSessionProposal: function(success, sessionProposal) {
d.setDetailsText(sessionProposal)
if (success) {
d.setStatusText("Pair ID: " + sessionProposal.id + "; Topic: " + sessionProposal.params.pairingTopic)
root.controller.pairSessionProposal(JSON.stringify(sessionProposal))
// Expecting signal onProposeUserPair from controller
} else {
d.setStatusText("Pairing error", "red")
}
}
onPairAcceptedResult: function(success, result) {
d.setDetailsText(result)
if (success) {
d.setStatusText("Pairing OK")
root.state = d.pairedState
} else {
d.setStatusText("Pairing error", "red")
root.state = d.sdkReadyState
}
}
onPairRejectedResult: function(success, result) {
d.setDetailsText(result)
root.state = d.sdkReadyState
if (success) {
d.setStatusText("Pairing rejected")
} else {
d.setStatusText("Rejecting pairing error", "red")
}
}
onStatusChanged: function(message) {
statusText.text = message
}
onResponseTimeout: {
d.setStatusText(`Timeout waiting for response. Reusing URI?`, "red")
}
}
}
QtObject {
id: d
property var sessionProposal: null
property var supportedNamespaces: null
readonly property string sdkReadyState: "sdk_ready"
readonly property string waitingPairState: "waiting_pairing"
readonly property string pairedState: "paired"
function setStatusText(message, textColor) {
statusText.text = message
if (textColor === undefined) {
textColor = "green"
}
statusText.color = textColor
}
function setDetailsText(message) {
if (message === undefined) {
message = "undefined"
} else if (typeof message !== "string") {
message = JSON.stringify(message, null, 2)
}
detailsText.text = message
}
}
Connections {
target: root.controller
function onProposeUserPair(sessionProposalJson, supportedNamespacesJson) {
d.setStatusText("Waiting user accept")
d.sessionProposal = JSON.parse(sessionProposalJson)
d.supportedNamespaces = JSON.parse(supportedNamespacesJson)
d.setDetailsText(JSON.stringify(d.supportedNamespaces, null, 2))
root.state = d.waitingPairState
}
}
}

View File

@ -22,30 +22,32 @@ WebView {
required property string projectId
required property color backgroundColor
readonly property string notReadyState: "not-ready"
readonly property string disconnectedState: "disconnected"
readonly property string waitingPairState: "waiting_pairing"
readonly property string pairedState: "paired"
readonly property alias sdkReady: d.sdkReady
state: root.notReadyState
property string optionalSdkPath: ""
signal sdkInit(bool success, var result)
signal pairSessionProposal(bool success, var sessionProposal)
signal pairAcceptedResult(bool success, var sessionType)
signal pairRejectedResult(bool success, var result)
signal responseTimeout()
// TODO: proper report
signal statusChanged(string message)
function pair(pairLink) {
d.requestSdk(
"wcResult = {error: null}; try { wc.pair(\"" + pairLink + "\").then((sessionProposal) => { wcResult = {state: \"" + root.waitingPairState + "\", error: null, sessionProposal: sessionProposal}; }).catch((error) => { wcResult = {error: error}; }); } catch (e) { wcResult = {error: \"Exception: \" + e.message}; }; wcResult"
)
let callStr = d.generateSdkCall("pair", `"${pairLink}"`, RequestCodes.PairSuccess, RequestCodes.PairError)
d.requestSdk(callStr)
}
function acceptPairing() {
d.acceptPair(d.sessionProposal)
function approvePairSession(sessionProposal, supportedNamespaces) {
let callStr = d.generateSdkCall("approvePairSession", `${JSON.stringify(sessionProposal)}, ${JSON.stringify(supportedNamespaces)}`, RequestCodes.ApprovePairSuccess, RequestCodes.ApprovePairSuccess)
d.requestSdk(callStr)
}
function rejectPairing() {
d.rejectPair(d.sessionProposal.id)
function rejectPairSession(id) {
let callStr = d.generateSdkCall("rejectPairSession", id, RequestCodes.RejectPairSuccess, RequestCodes.RejectPairError)
d.requestSdk(callStr)
}
// TODO #12434: remove debugging WebEngineView code
@ -101,47 +103,55 @@ WebView {
onTriggered: {
root.runJavaScript(
"wcResult",
function(result) {
if (!result) {
function(wcResult) {
if (!wcResult) {
return
}
let done = false
if (result.error) {
if (wcResult.error) {
console.debug(`@dd wcResult - ${JSON.stringify(wcResult)}`)
done = true
if (root.state === root.notReadyState) {
root.statusChanged(`<font color="red">[${timer.errorCount++}] Failed SDK init; error: ${result.error}</font>`)
if (!d.sdkReady) {
root.statusChanged(`<font color="red">[${timer.errorCount++}] Failed SDK init; error: ${wcResult.error}</font>`)
} else {
root.state = root.disconnectedState
root.statusChanged(`<font color="red">[${timer.errorCount++}] Operation error: ${result.error}</font>`)
}
} else if (result.state) {
switch (result.state) {
case root.disconnectedState: {
root.statusChanged(`<font color="green">Ready to pair or auth</font>`)
break
}
case root.waitingPairState: {
d.sessionProposal = result.sessionProposal
root.statusChanged("Pair ID: " + result.sessionProposal.id + "; Topic: " + result.sessionProposal.params.pairingTopic)
break
}
case root.pairedState: {
d.sessionType = result.sessionType
root.statusChanged(`<font color="blue">Paired: ${JSON.stringify(result.sessionType)}</font>`)
break
}
case root.disconnectedState: {
root.statusChanged(`<font color="orange">User rejected PairID ${d.sessionProposal.id}</font>`)
break
}
default: {
root.statusChanged(`<font color="red">[${timer.errorCount++}] Unknown state: ${result.state}</font>`)
result.state = root.disconnectedState
root.statusChanged(`<font color="red">[${timer.errorCount++}] Operation error: ${wcResult.error}</font>`)
}
}
if (wcResult.state !== undefined) {
switch (wcResult.state) {
case RequestCodes.SdkInitSuccess:
d.sdkReady = true
root.sdkInit(true, "")
break
case RequestCodes.SdkInitError:
d.sdkReady = false
root.sdkInit(false, wcResult.error)
break
case RequestCodes.PairSuccess:
root.pairSessionProposal(true, wcResult.result)
break
case RequestCodes.PairError:
root.pairSessionProposal(false, wcResult.error)
break
case RequestCodes.ApprovePairSuccess:
root.pairAcceptedResult(true, "")
break
case RequestCodes.ApprovePairError:
root.pairAcceptedResult(false, wcResult.error)
break
case RequestCodes.RejectPairSuccess:
root.pairRejectedResult(true, "")
break
case RequestCodes.RejectPairError:
root.pairRejectedResult(false, wcResult.error)
break
default: {
root.statusChanged(`<font color="red">[${timer.errorCount++}] Unknown state: ${wcResult.state}</font>`)
}
}
root.state = result.state
done = true
}
@ -162,8 +172,7 @@ WebView {
onTriggered: {
timer.stop()
root.state = root.disconnectedState
root.statusChanged(`<font color="red">Timeout waiting for response. The pairing might have been already attempted for the URI.</font>`)
root.responseTimeout()
}
}
@ -172,37 +181,27 @@ WebView {
property var sessionProposal: null
property var sessionType: null
property bool sdkReady: false
function isWaitingForSdk() {
return timer.running
}
function generateSdkCall(methodName, paramsStr, successState, errorState) {
return "wcResult = {error: null}; try { wc." + methodName + "(" + paramsStr + ").then((callRes) => { wcResult = {state: " + successState + ", error: null, result: callRes}; }).catch((error) => { wcResult = {state: " + errorState + ", error: error}; }); } catch (e) { wcResult = {state: " + errorState + ", error: \"Exception: \" + e.message}; }; wcResult"
}
function requestSdk(jsCode) {
console.debug(`@dd WalletConnectSDK.requestSdk; jsCode: ${jsCode}`)
root.runJavaScript(jsCode,
function(result) {
console.debug(`@dd WalletConnectSDK.requestSdk; result: ${JSON.stringify(result)}`)
timer.restart()
}
)
}
function init(projectId) {
d.requestSdk(
"wcResult = {error: null}; try { wc.init(\"" + projectId + "\").then((wc) => { wcResult = {state: \"" + root.disconnectedState + "\", error: null}; }).catch((error) => { wcResult = {error: error}; }); } catch (e) { wcResult = {error: \"Exception: \" + e.message}; }; wcResult"
)
}
function acceptPair(sessionProposal) {
d.requestSdk(
"wcResult = {error: null}; try { wc.approveSession(" + JSON.stringify(sessionProposal) + ").then((sessionType) => { wcResult = {state: \"" + root.pairedState + "\", error: null, sessionType: sessionType}; }).catch((error) => { wcResult = {error: error}; }); } catch (e) { wcResult = {error: \"Exception: \" + e.message}; }; wcResult"
)
}
function rejectPair(id) {
d.requestSdk(
"wcResult = {error: null}; try { wc.rejectSession(" + JSON.stringify(id) + ").then(() => { wcResult = {state: \"" + root.disconnectedState + "\", error: null}; }).catch((error) => { wcResult = {error: error}; }); } catch (e) { wcResult = {error: \"Exception: \" + e.message}; }; wcResult"
)
console.debug(`@dd WC projectId - ${projectId}`)
d.requestSdk(generateSdkCall("init", `"${projectId}"`, RequestCodes.SdkInitSuccess, RequestCodes.SdkInitError))
}
}
}

View File

@ -1,5 +1,25 @@
# Wallet Connect Integration
## TODO
- [ ] test namespaces implementation https://se-sdk-dapp.vercel.app/
Design questions
- [ ] Do we report all chains and all accounts combination or let user select?
- Wallet Connect require to report all chainIDs that were requested
- Show error to user workflow.
- [ ] Can't respond to sign messages if the wallet-connect dialog/view is closed (app is minimized)
- Only apps that use deep links are expected to work seamlessly
- [ ] Do we report **disabled chains**? **Update session** in case of enabled/disabled chains?
- [ ] Allow user to **disconnect session**?
- [ ] Support update session if one account is added/removed?
- [ ] User awareness of session expiration?
- Support extend session?
- [ ] User error workflow: retry?
- [ ] Check the `Auth` request for verifyContext <https://docs.walletconnect.com/web3wallet/verify>
- [ ] What `description` and `icons` to use for the app? See `metadata` parameter in `Web3Wallet.init` call
## WalletConnect SDK management
Install dependencies steps by executing commands in this directory:
@ -17,9 +37,6 @@ Install dependencies steps by executing commands in this directory:
Use the web demo test client https://react-app.walletconnect.com/ for wallet pairing and https://react-auth-dapp.walletconnect.com/ for authentication
## TODO
- [ ] test namespaces implementation https://se-sdk-dapp.vercel.app/
## Log
@ -37,6 +54,7 @@ npm run build
To test SDK loading add the following to `ui/app/mainui/AppMain.qml`
```qml
import AppLayouts.Wallet.stores 1.0 as WalletStores
import AppLayouts.Wallet.views.walletconnect 1.0
// ...
@ -49,8 +67,10 @@ StatusDialog {
SplitView.preferredWidth: 400
SplitView.preferredHeight: 600
projectId: "<Project ID>"
backgroundColor: wcHelperDialog.backgroundColor
projectId: "<Project ID>"
controller: WalletStores.RootStore.wcController
}
clip: true

File diff suppressed because one or more lines are too long

View File

@ -22,10 +22,9 @@ window.wc = {
window.wc.web3wallet = await Web3Wallet.init({
core: window.wc.core, // <- pass the shared `core` instance
metadata: {
// TODO: what values should be here?
name: "Prototype",
description: "Prototype Wallet/Peer",
url: "https://github.com/status-im/status-desktop",
name: "Status",
description: "Status Wallet",
url: "https://status.app",
icons: ['https://status.im/img/status-footer-logo.svg'],
},
});
@ -35,7 +34,7 @@ window.wc = {
metadata: window.wc.web3wallet.metadata,
})
resolve(window.wc)
resolve()
})
},
@ -45,10 +44,6 @@ window.wc = {
pair: function (uri) {
let pairingTopic = getPairingTopicFromPairingUrl(uri);
let pairPromise = window.wc.web3wallet
.pair({ uri: uri })
.catch((error) => console.error(error));
const pairings = window.wc.core.pairing.getPairings();
// Find pairing by topic
const pairing = pairings.find((p) => p.topic === pairingTopic);
@ -60,6 +55,10 @@ window.wc = {
}
}
let pairPromise = window.wc.web3wallet
.pair({ uri: uri })
return new Promise((resolve, reject) => {
pairPromise
.then(() => {
@ -77,36 +76,24 @@ window.wc = {
window.wc.web3wallet.on("session_request", callback);
},
// TODO: ensure if session requests only one account we don't provide all accounts
approveSession: function (sessionProposal) {
registerForSessionDelete: function (callback) {
window.wc.web3wallet.on("session_delete", callback);
},
approvePairSession: function (sessionProposal, supportedNamespaces) {
const { id, params } = sessionProposal;
// ------- namespaces builder util ------------ //
const approvedNamespaces = buildApprovedNamespaces({
proposal: params,
// TODO: source this from wallet
supportedNamespaces: {
eip155: {
chains: ["eip155:1", "eip155:5"],
methods: ["eth_sendTransaction", "personal_sign"],
events: ["accountsChanged", "chainChanged"],
accounts: [
"eip155:1:0x0000000000000000000000000000000000000001",
"eip155:5:0xe74E17D586227691Cb7b64ed78b1b7B14828B034",
],
},
},
supportedNamespaces: supportedNamespaces,
});
// ------- end namespaces builder util ------------ //
const session = window.wc.web3wallet.approveSession({
return window.wc.web3wallet.approveSession({
id,
namespaces: approvedNamespaces,
});
return session;
},
rejectSession: function (id) {
rejectPairSession: function (id) {
return window.wc.web3wallet.rejectSession({
id: id,
reason: getSdkError("USER_REJECTED"), // TODO USER_REJECTED_METHODS, USER_REJECTED_CHAINS, USER_REJECTED_EVENTS
@ -116,10 +103,6 @@ window.wc = {
auth: function (uri) {
let pairingTopic = getPairingTopicFromPairingUrl(uri);
let pairPromise = window.wc.authClient.core.pairing
.pair({ uri })
.catch((error) => console.error(error));
const pairings = window.wc.core.pairing.getPairings();
// Find pairing by topic
const pairing = pairings.find((p) => p.topic === pairingTopic);
@ -131,6 +114,9 @@ window.wc = {
}
}
let pairPromise = window.wc.authClient.core.pairing
.pair({ uri })
return new Promise((resolve, reject) => {
pairPromise
.then(() => {