From b994cedfc3980940fef94563ec835a1439d2586e Mon Sep 17 00:00:00 2001 From: Stefan Date: Mon, 6 Nov 2023 21:04:42 +0200 Subject: [PATCH] feat(wallet) implement Wallet Connect sign APIs add WalletConnect support for signing session events - implement `eth_sendTransaction` - implement `personal_sign` Also fix exposing unusable accounts Updates status-desktop #12637 --- .vscode/settings.json | 3 + multiaccounts/accounts/database.go | 2 +- services/wallet/api.go | 14 ++ services/wallet/service.go | 2 +- services/wallet/walletconnect/helpers.go | 21 +++ services/wallet/walletconnect/rpc.go | 126 ++++++++++++++++++ services/wallet/walletconnect/service.go | 43 +++++- .../wallet/walletconnect/walletconnect.go | 38 ++++-- services/web3provider/api.go | 1 - 9 files changed, 235 insertions(+), 15 deletions(-) create mode 100644 services/wallet/walletconnect/rpc.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 962c80034..16fdfe542 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,4 +5,7 @@ "-w" ], "go.testTags": "gowaku_skip_migrations,gowaku_no_rln", + "cSpell.words": [ + "unmarshalling" + ], } diff --git a/multiaccounts/accounts/database.go b/multiaccounts/accounts/database.go index 518ff05be..4b832d4db 100644 --- a/multiaccounts/accounts/database.go +++ b/multiaccounts/accounts/database.go @@ -68,7 +68,7 @@ type Account struct { Hidden bool `json:"hidden"` Clock uint64 `json:"clock,omitempty"` Removed bool `json:"removed,omitempty"` - Operable AccountOperable `json:"operable"` // describes an account's operability (read an explanation at the top of this file) + Operable AccountOperable `json:"operable"` // describes an account's operability (check AccountOperable type constants for details) CreatedAt int64 `json:"createdAt"` Position int64 `json:"position"` ProdPreferredChainIDs string `json:"prodPreferredChainIds"` diff --git a/services/wallet/api.go b/services/wallet/api.go index 809470995..ed69ed3a7 100644 --- a/services/wallet/api.go +++ b/services/wallet/api.go @@ -608,6 +608,7 @@ func (api *API) FetchChainIDForURL(ctx context.Context, rpcURL string) (*big.Int return client.ChainID(ctx) } +// WCPairSessionProposal responds to "session_proposal" event func (api *API) WCPairSessionProposal(ctx context.Context, sessionProposalJSON string) (*wc.PairSessionResponse, error) { log.Debug("wallet.api.wc.PairSessionProposal", "proposal.len", len(sessionProposalJSON)) @@ -619,3 +620,16 @@ func (api *API) WCPairSessionProposal(ctx context.Context, sessionProposalJSON s return api.s.walletConnect.PairSessionProposal(data) } + +// WCSessionRequest responds to "session_request" event +func (api *API) WCSessionRequest(ctx context.Context, sessionRequestJSON string, hashedPassword string) (response *wc.SessionRequestResponse, err error) { + log.Debug("wallet.api.wc.SessionRequest", "request.len", len(sessionRequestJSON), "hashedPassword.len", len(hashedPassword)) + + var request wc.SessionRequest + err = json.Unmarshal([]byte(sessionRequestJSON), &request) + if err != nil { + return nil, err + } + + return api.s.walletConnect.SessionRequest(request, hashedPassword) +} diff --git a/services/wallet/service.go b/services/wallet/service.go index 11ad0aa59..af98c17e2 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -137,7 +137,7 @@ func NewService( activity := activity.NewService(db, tokenManager, collectiblesManager, feed) - walletconnect := walletconnect.NewService(rpcClient.NetworkManager, accountsDB, feed) + walletconnect := walletconnect.NewService(rpcClient.NetworkManager, accountsDB, transactor, gethManager, feed) return &Service{ db: db, diff --git a/services/wallet/walletconnect/helpers.go b/services/wallet/walletconnect/helpers.go index 74feab768..d3b357bfd 100644 --- a/services/wallet/walletconnect/helpers.go +++ b/services/wallet/walletconnect/helpers.go @@ -1,6 +1,7 @@ package walletconnect import ( + "encoding/json" "errors" "fmt" "strconv" @@ -39,3 +40,23 @@ func parseCaip2ChainID(str string) (uint64, error) { } return chainID, nil } + +// JSONProxyType provides a generic way of changing the JSON value before unmarshalling it into the target. +// transform function is called before unmarshalling. +type JSONProxyType struct { + target interface{} + transform func([]byte) ([]byte, error) +} + +func (b *JSONProxyType) UnmarshalJSON(input []byte) error { + if b.transform == nil { + return errors.New("transform function is not set") + } + + output, err := b.transform(input) + if err != nil { + return err + } + + return json.Unmarshal(output, b.target) +} diff --git a/services/wallet/walletconnect/rpc.go b/services/wallet/walletconnect/rpc.go new file mode 100644 index 000000000..d6ecc7e46 --- /dev/null +++ b/services/wallet/walletconnect/rpc.go @@ -0,0 +1,126 @@ +package walletconnect + +import ( + "encoding/json" + "strings" + + "github.com/status-im/status-go/eth-node/crypto" + "github.com/status-im/status-go/eth-node/types" + "github.com/status-im/status-go/transactions" +) + +// sendTransactionParams instead of transactions.SendTxArgs to allow parsing of hex Uint64 with leading 0 ("0x01") and empty hex value ("0x") +type sendTransactionParams struct { + transactions.SendTxArgs + Nonce JSONProxyType `json:"nonce"` + Gas JSONProxyType `json:"gas"` + GasPrice JSONProxyType `json:"gasPrice"` + Value JSONProxyType `json:"value"` + MaxFeePerGas JSONProxyType `json:"maxFeePerGas"` + MaxPriorityFeePerGas JSONProxyType `json:"maxPriorityFeePerGas"` +} + +func (n *sendTransactionParams) UnmarshalJSON(data []byte) error { + // Avoid recursion + type Alias sendTransactionParams + var alias Alias + // Fix hex values with leading 0 or empty + fixWCHexValues := func(input []byte) ([]byte, error) { + hexStr := string(input) + if !strings.HasPrefix(hexStr, "\"0x") { + return input, nil + } + trimmedStr := strings.TrimPrefix(hexStr, "\"0x") + fixedStrNoPrefix := strings.TrimLeft(trimmedStr, "0") + fixedStr := "\"0x" + fixedStrNoPrefix + if fixedStr == "\"0x\"" { + fixedStr = "\"0x0\"" + } + + return []byte(fixedStr), nil + } + + alias.Nonce = JSONProxyType{target: &alias.SendTxArgs.Nonce, transform: fixWCHexValues} + alias.Gas = JSONProxyType{target: &alias.SendTxArgs.Gas, transform: fixWCHexValues} + alias.GasPrice = JSONProxyType{target: &alias.SendTxArgs.GasPrice, transform: fixWCHexValues} + alias.Value = JSONProxyType{target: &alias.SendTxArgs.Value, transform: fixWCHexValues} + alias.MaxFeePerGas = JSONProxyType{target: &alias.SendTxArgs.MaxFeePerGas, transform: fixWCHexValues} + alias.MaxPriorityFeePerGas = JSONProxyType{target: &alias.SendTxArgs.MaxPriorityFeePerGas, transform: fixWCHexValues} + + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *n = sendTransactionParams(alias) + return nil +} + +func (n *sendTransactionParams) MarshalJSON() ([]byte, error) { + return json.Marshal(n.SendTxArgs) +} + +func (s *Service) sendTransaction(request SessionRequest, hashedPassword string) (response *SessionRequestResponse, err error) { + if len(request.Params.Request.Params) != 1 { + return nil, ErrorInvalidParamsCount + } + + var params sendTransactionParams + if err := json.Unmarshal(request.Params.Request.Params[0], ¶ms); err != nil { + return nil, err + } + + acc, err := s.gethManager.GetVerifiedWalletAccount(s.accountsDB, params.From.Hex(), hashedPassword) + if err != nil { + return nil, err + } + + // TODO: export it as a JSON parsable type + chainID, err := parseCaip2ChainID(request.Params.ChainID) + if err != nil { + return nil, err + } + + hash, err := s.transactor.SendTransactionWithChainID(chainID, params.SendTxArgs, acc) + if err != nil { + return nil, err + } + + return &SessionRequestResponse{ + SessionRequest: request, + Signed: hash.Bytes(), + }, nil +} + +func (s *Service) personalSign(request SessionRequest, hashedPassword string) (response *SessionRequestResponse, err error) { + if len(request.Params.Request.Params) != 2 { + return nil, ErrorInvalidParamsCount + } + + var address types.Address + if err := json.Unmarshal(request.Params.Request.Params[1], &address); err != nil { + return nil, err + } + + acc, err := s.gethManager.GetVerifiedWalletAccount(s.accountsDB, address.Hex(), hashedPassword) + if err != nil { + return nil, err + } + + var dBytes types.HexBytes + if err := json.Unmarshal(request.Params.Request.Params[0], &dBytes); err != nil { + return nil, err + } + + hash := crypto.TextHash(dBytes) + + sig, err := crypto.Sign(hash, acc.AccountKey.PrivateKey) + if err != nil { + return nil, err + } + + sig[64] += 27 // Transform V from 0/1 to 27/28 according to the yellow paper + + return &SessionRequestResponse{ + SessionRequest: request, + Signed: types.HexBytes(sig), + }, nil +} diff --git a/services/wallet/walletconnect/service.go b/services/wallet/walletconnect/service.go index 742351947..1fe5542be 100644 --- a/services/wallet/walletconnect/service.go +++ b/services/wallet/walletconnect/service.go @@ -1,28 +1,39 @@ package walletconnect import ( + "fmt" + "github.com/ethereum/go-ethereum/event" + "github.com/ethereum/go-ethereum/log" + "github.com/status-im/status-go/account" "github.com/status-im/status-go/multiaccounts/accounts" + "github.com/status-im/status-go/params" "github.com/status-im/status-go/rpc/network" + "github.com/status-im/status-go/transactions" ) type Service struct { networkManager *network.Manager accountsDB *accounts.Database eventFeed *event.Feed + + transactor *transactions.Transactor + gethManager *account.GethManager } -func NewService(networkManager *network.Manager, accountsDB *accounts.Database, eventFeed *event.Feed) *Service { +func NewService(networkManager *network.Manager, accountsDB *accounts.Database, transactor *transactions.Transactor, gethManager *account.GethManager, eventFeed *event.Feed) *Service { return &Service{ networkManager: networkManager, accountsDB: accountsDB, eventFeed: eventFeed, + transactor: transactor, + gethManager: gethManager, } } func (s *Service) PairSessionProposal(proposal SessionProposal) (*PairSessionResponse, error) { namespace := Namespace{ - Methods: []string{"eth_sendTransaction", "personal_sign"}, + Methods: []string{params.SendTransactionMethodName, params.PersonalSignMethodName}, Events: []string{"accountsChanged", "chainChanged"}, } @@ -31,16 +42,26 @@ func (s *Service) PairSessionProposal(proposal SessionProposal) (*PairSessionRes return s.networkManager.Find(chainID) != nil }) if len(chains) != len(proposedChains) { + log.Warn("Some chains are not supported; wanted: ", proposedChains, "; supported: ", chains) return nil, ErrorChainsNotSupported } namespace.Chains = eipChains activeAccounts, err := s.accountsDB.GetActiveAccounts() if err != nil { - return nil, ErrorChainsNotSupported + return nil, fmt.Errorf("failed to get active accounts: %w", err) } - addresses := activeToOwnedAccounts(activeAccounts) + // Filter out non-own accounts + usableAccounts := make([]*accounts.Account, 0, 1) + for _, acc := range activeAccounts { + if !acc.IsOwnAccount() || acc.Operable != accounts.AccountFullyOperable { + continue + } + usableAccounts = append(usableAccounts, acc) + } + + addresses := activeToOwnedAccounts(usableAccounts) namespace.Accounts = caip10Accounts(addresses, chains) // TODO #12434: respond async @@ -51,3 +72,17 @@ func (s *Service) PairSessionProposal(proposal SessionProposal) (*PairSessionRes }, }, nil } + +func (s *Service) SessionRequest(request SessionRequest, hashedPassword string) (response *SessionRequestResponse, err error) { + // TODO #12434: should we check topic for validity? It might make sense if we + // want to cache the paired sessions + + if request.Params.Request.Method == params.SendTransactionMethodName { + return s.sendTransaction(request, hashedPassword) + } else if request.Params.Request.Method == params.PersonalSignMethodName { + return s.personalSign(request, hashedPassword) + } + + // TODO #12434: respond async + return nil, ErrorMethodNotSupported +} diff --git a/services/wallet/walletconnect/walletconnect.go b/services/wallet/walletconnect/walletconnect.go index 375eb6ea9..bae7670fe 100644 --- a/services/wallet/walletconnect/walletconnect.go +++ b/services/wallet/walletconnect/walletconnect.go @@ -1,6 +1,7 @@ package walletconnect import ( + "encoding/json" "errors" "strconv" @@ -14,6 +15,8 @@ import ( const ProposeUserPairEvent = walletevent.EventType("WalletConnectProposeUserPair") var ErrorChainsNotSupported = errors.New("chains not supported") +var ErrorInvalidParamsCount = errors.New("invalid params count") +var ErrorMethodNotSupported = errors.New("method not supported") type Namespace struct { Methods []string `json:"methods"` @@ -40,14 +43,13 @@ type Namespaces struct { // We ignore non ethereum namespaces } -type Verified struct { - VerifyURL string `json:"verifyUrl"` - Validation string `json:"validation"` - Origin string `json:"origin"` -} - type VerifyContext struct { - Verified Verified `json:"verified"` + Verified struct { + VerifyURL string `json:"verifyUrl"` + Validation string `json:"validation"` + Origin string `json:"origin"` + IsScam bool `json:"isScam,omitempty"` + } `json:"verified"` } type Params struct { @@ -57,7 +59,7 @@ type Params struct { RequiredNamespaces Namespaces `json:"requiredNamespaces"` OptionalNamespaces Namespaces `json:"optionalNamespaces"` Proposer Proposer `json:"proposer"` - VerifyContext VerifyContext `json:"verifyContext"` + Verify VerifyContext `json:"verifyContext"` } type SessionProposal struct { @@ -70,6 +72,26 @@ type PairSessionResponse struct { SupportedNamespaces Namespaces `json:"supportedNamespaces"` } +type RequestParams struct { + Request struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } `json:"request"` + ChainID string `json:"chainId"` +} + +type SessionRequest struct { + ID int64 `json:"id"` + Topic string `json:"topic"` + Params RequestParams `json:"params"` + Verify VerifyContext `json:"verifyContext"` +} + +type SessionRequestResponse struct { + SessionRequest SessionRequest `json:"sessionRequest"` + Signed types.HexBytes `json:"signed"` +} + func sessionProposalToSupportedChain(caipChains []string, supportsChain func(uint64) bool) (chains []uint64, eipChains []string) { chains = make([]uint64, 0, 1) eipChains = make([]string, 0, 1) diff --git a/services/web3provider/api.go b/services/web3provider/api.go index d517e0a39..9e192d5a8 100644 --- a/services/web3provider/api.go +++ b/services/web3provider/api.go @@ -219,7 +219,6 @@ func (api *API) web3AccResponse(request Web3SendAsyncReadOnlyRequest) (*Web3Send result = dappsAddress } else { result = []types.Address{dappsAddress} - } return &Web3SendAsyncReadOnlyResponse{