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
This commit is contained in:
Stefan 2023-11-06 21:04:42 +02:00 committed by Stefan Dunca
parent f1d31d6339
commit b994cedfc3
9 changed files with 235 additions and 15 deletions

View File

@ -5,4 +5,7 @@
"-w" "-w"
], ],
"go.testTags": "gowaku_skip_migrations,gowaku_no_rln", "go.testTags": "gowaku_skip_migrations,gowaku_no_rln",
"cSpell.words": [
"unmarshalling"
],
} }

View File

@ -68,7 +68,7 @@ type Account struct {
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
Clock uint64 `json:"clock,omitempty"` Clock uint64 `json:"clock,omitempty"`
Removed bool `json:"removed,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"` CreatedAt int64 `json:"createdAt"`
Position int64 `json:"position"` Position int64 `json:"position"`
ProdPreferredChainIDs string `json:"prodPreferredChainIds"` ProdPreferredChainIDs string `json:"prodPreferredChainIds"`

View File

@ -608,6 +608,7 @@ func (api *API) FetchChainIDForURL(ctx context.Context, rpcURL string) (*big.Int
return client.ChainID(ctx) return client.ChainID(ctx)
} }
// WCPairSessionProposal responds to "session_proposal" event
func (api *API) WCPairSessionProposal(ctx context.Context, sessionProposalJSON string) (*wc.PairSessionResponse, error) { func (api *API) WCPairSessionProposal(ctx context.Context, sessionProposalJSON string) (*wc.PairSessionResponse, error) {
log.Debug("wallet.api.wc.PairSessionProposal", "proposal.len", len(sessionProposalJSON)) 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) 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)
}

View File

@ -137,7 +137,7 @@ func NewService(
activity := activity.NewService(db, tokenManager, collectiblesManager, feed) 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{ return &Service{
db: db, db: db,

View File

@ -1,6 +1,7 @@
package walletconnect package walletconnect
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
@ -39,3 +40,23 @@ func parseCaip2ChainID(str string) (uint64, error) {
} }
return chainID, nil 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)
}

View File

@ -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], &params); 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
}

View File

@ -1,28 +1,39 @@
package walletconnect package walletconnect
import ( import (
"fmt"
"github.com/ethereum/go-ethereum/event" "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/multiaccounts/accounts"
"github.com/status-im/status-go/params"
"github.com/status-im/status-go/rpc/network" "github.com/status-im/status-go/rpc/network"
"github.com/status-im/status-go/transactions"
) )
type Service struct { type Service struct {
networkManager *network.Manager networkManager *network.Manager
accountsDB *accounts.Database accountsDB *accounts.Database
eventFeed *event.Feed 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{ return &Service{
networkManager: networkManager, networkManager: networkManager,
accountsDB: accountsDB, accountsDB: accountsDB,
eventFeed: eventFeed, eventFeed: eventFeed,
transactor: transactor,
gethManager: gethManager,
} }
} }
func (s *Service) PairSessionProposal(proposal SessionProposal) (*PairSessionResponse, error) { func (s *Service) PairSessionProposal(proposal SessionProposal) (*PairSessionResponse, error) {
namespace := Namespace{ namespace := Namespace{
Methods: []string{"eth_sendTransaction", "personal_sign"}, Methods: []string{params.SendTransactionMethodName, params.PersonalSignMethodName},
Events: []string{"accountsChanged", "chainChanged"}, Events: []string{"accountsChanged", "chainChanged"},
} }
@ -31,16 +42,26 @@ func (s *Service) PairSessionProposal(proposal SessionProposal) (*PairSessionRes
return s.networkManager.Find(chainID) != nil return s.networkManager.Find(chainID) != nil
}) })
if len(chains) != len(proposedChains) { if len(chains) != len(proposedChains) {
log.Warn("Some chains are not supported; wanted: ", proposedChains, "; supported: ", chains)
return nil, ErrorChainsNotSupported return nil, ErrorChainsNotSupported
} }
namespace.Chains = eipChains namespace.Chains = eipChains
activeAccounts, err := s.accountsDB.GetActiveAccounts() activeAccounts, err := s.accountsDB.GetActiveAccounts()
if err != nil { 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) namespace.Accounts = caip10Accounts(addresses, chains)
// TODO #12434: respond async // TODO #12434: respond async
@ -51,3 +72,17 @@ func (s *Service) PairSessionProposal(proposal SessionProposal) (*PairSessionRes
}, },
}, nil }, 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
}

View File

@ -1,6 +1,7 @@
package walletconnect package walletconnect
import ( import (
"encoding/json"
"errors" "errors"
"strconv" "strconv"
@ -14,6 +15,8 @@ import (
const ProposeUserPairEvent = walletevent.EventType("WalletConnectProposeUserPair") const ProposeUserPairEvent = walletevent.EventType("WalletConnectProposeUserPair")
var ErrorChainsNotSupported = errors.New("chains not supported") var ErrorChainsNotSupported = errors.New("chains not supported")
var ErrorInvalidParamsCount = errors.New("invalid params count")
var ErrorMethodNotSupported = errors.New("method not supported")
type Namespace struct { type Namespace struct {
Methods []string `json:"methods"` Methods []string `json:"methods"`
@ -40,14 +43,13 @@ type Namespaces struct {
// We ignore non ethereum namespaces // We ignore non ethereum namespaces
} }
type Verified struct { type VerifyContext struct {
Verified struct {
VerifyURL string `json:"verifyUrl"` VerifyURL string `json:"verifyUrl"`
Validation string `json:"validation"` Validation string `json:"validation"`
Origin string `json:"origin"` Origin string `json:"origin"`
} IsScam bool `json:"isScam,omitempty"`
} `json:"verified"`
type VerifyContext struct {
Verified Verified `json:"verified"`
} }
type Params struct { type Params struct {
@ -57,7 +59,7 @@ type Params struct {
RequiredNamespaces Namespaces `json:"requiredNamespaces"` RequiredNamespaces Namespaces `json:"requiredNamespaces"`
OptionalNamespaces Namespaces `json:"optionalNamespaces"` OptionalNamespaces Namespaces `json:"optionalNamespaces"`
Proposer Proposer `json:"proposer"` Proposer Proposer `json:"proposer"`
VerifyContext VerifyContext `json:"verifyContext"` Verify VerifyContext `json:"verifyContext"`
} }
type SessionProposal struct { type SessionProposal struct {
@ -70,6 +72,26 @@ type PairSessionResponse struct {
SupportedNamespaces Namespaces `json:"supportedNamespaces"` 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) { func sessionProposalToSupportedChain(caipChains []string, supportsChain func(uint64) bool) (chains []uint64, eipChains []string) {
chains = make([]uint64, 0, 1) chains = make([]uint64, 0, 1)
eipChains = make([]string, 0, 1) eipChains = make([]string, 0, 1)

View File

@ -219,7 +219,6 @@ func (api *API) web3AccResponse(request Web3SendAsyncReadOnlyRequest) (*Web3Send
result = dappsAddress result = dappsAddress
} else { } else {
result = []types.Address{dappsAddress} result = []types.Address{dappsAddress}
} }
return &Web3SendAsyncReadOnlyResponse{ return &Web3SendAsyncReadOnlyResponse{