feat(walletconnect)_: support for session proposal for wc 2.0

This commit is contained in:
Sale Djenic 2023-11-24 16:27:05 +01:00 committed by saledjenic
parent e32c5546e1
commit d4ca8616fc
7 changed files with 389 additions and 132 deletions

View File

@ -1 +1 @@
0.171.17 0.171.18

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
) )
@ -26,18 +27,19 @@ import (
// log.Debug("wallet.api.wc RESPONSE", "eventType", eventType, "error", err, "payload.len", len(payload), "sentCount", sentCount) // log.Debug("wallet.api.wc RESPONSE", "eventType", eventType, "error", err, "payload.len", len(payload), "sentCount", sentCount)
// } // }
func parseCaip2ChainID(str string) (uint64, error) { // Returns namspace name, chainID and error
func parseCaip2ChainID(str string) (string, uint64, error) {
caip2 := strings.Split(str, ":") caip2 := strings.Split(str, ":")
if len(caip2) != 2 { if len(caip2) != 2 {
return 0, errors.New("CAIP-2 string is not valid") return "", 0, errors.New("CAIP-2 string is not valid")
} }
chainIDStr := caip2[1] chainIDStr := caip2[1]
chainID, err := strconv.ParseUint(chainIDStr, 10, 64) chainID, err := strconv.ParseUint(chainIDStr, 10, 64)
if err != nil { if err != nil {
return 0, fmt.Errorf("CAIP-2 second value not valid Chain ID: %w", err) return "", 0, fmt.Errorf("CAIP-2 second value not valid Chain ID: %w", err)
} }
return chainID, nil return caip2[0], chainID, nil
} }
// JSONProxyType provides a generic way of changing the JSON value before unmarshalling it into the target. // JSONProxyType provides a generic way of changing the JSON value before unmarshalling it into the target.
@ -59,3 +61,11 @@ func (b *JSONProxyType) UnmarshalJSON(input []byte) error {
return json.Unmarshal(output, b.target) return json.Unmarshal(output, b.target)
} }
func isValidNamespaceName(namespaceName string) bool {
pattern := "^[a-z0-9-]{3,8}$"
regex := regexp.MustCompile(pattern)
return regex.MatchString(namespaceName)
}

View File

@ -1,60 +1,66 @@
package walletconnect package walletconnect
import "testing" import (
"strings"
"testing"
)
func Test_parseCaip2ChainID(t *testing.T) { func Test_parseCaip2(t *testing.T) {
type args struct { type args struct {
str string str string
} }
tests := []struct { tests := []struct {
name string name string
args args args args
want uint64 wantChain uint64
wantErr bool wantErr bool
}{ }{
{ {
name: "valid", name: "valid",
args: args{ args: args{
str: "eip155:5", str: "eip155:5",
}, },
want: 5, wantChain: 5,
wantErr: false, wantErr: false,
}, },
{ {
name: "invalid_number", name: "invalid_number",
args: args{ args: args{
str: "eip155:5a", str: "eip155:5a",
}, },
want: 0, wantChain: 0,
wantErr: true, wantErr: true,
}, },
{ {
name: "invalid_caip2_too_many", name: "invalid_caip2_too_many",
args: args{ args: args{
str: "eip155:1:5", str: "eip155:1:5",
}, },
want: 0, wantChain: 0,
wantErr: true, wantErr: true,
}, },
{ {
name: "invalid_caip2_not_enough", name: "invalid_caip2_not_enough",
args: args{ args: args{
str: "eip1551", str: "eip1551",
}, },
want: 0, wantChain: 0,
wantErr: true, wantErr: true,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := parseCaip2ChainID(tt.args.str) gotNamespaceName, gotChainID, err := parseCaip2ChainID(tt.args.str)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("parseCaip2ChainID() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("parseCaip2ChainID() error = %v, wantErr %v", err, tt.wantErr)
return return
} }
if got != tt.want { if !tt.wantErr && !strings.Contains(tt.args.str, gotNamespaceName) {
t.Errorf("parseCaip2ChainID() = %v, want %v", got, tt.want) t.Errorf("parseCaip2ChainID() = %v, doesn't match %v", gotNamespaceName, tt.args.str)
}
if gotChainID != tt.wantChain {
t.Errorf("parseCaip2ChainID() = %v, want %v", gotChainID, tt.wantChain)
} }
}) })
} }

View File

@ -85,7 +85,7 @@ func (s *Service) buildTransaction(request SessionRequest) (response *SessionReq
return nil, err return nil, err
} }
chainID, err := parseCaip2ChainID(request.Params.ChainID) _, chainID, err := parseCaip2ChainID(request.Params.ChainID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -3,6 +3,7 @@ package walletconnect
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
@ -66,45 +67,79 @@ func (s *Service) SendTransaction(signature string) (response *SessionRequestRes
} }
func (s *Service) PairSessionProposal(proposal SessionProposal) (*PairSessionResponse, error) { func (s *Service) PairSessionProposal(proposal SessionProposal) (*PairSessionResponse, error) {
namespace := Namespace{ if !proposal.Valid() {
Methods: []string{params.SendTransactionMethodName, params.PersonalSignMethodName}, return nil, ErrorInvalidSessionProposal
Events: []string{"accountsChanged", "chainChanged"},
} }
proposedChains := proposal.Params.RequiredNamespaces.Eip155.Chains var (
chains, eipChains := sessionProposalToSupportedChain(proposedChains, func(chainID uint64) bool { chains []uint64
return s.networkManager.Find(chainID) != nil eipChains []string
}) )
if len(chains) != len(proposedChains) {
log.Warn("Some chains are not supported; wanted: ", proposedChains, "; supported: ", chains) if len(proposal.Params.RequiredNamespaces) == 0 {
return nil, ErrorChainsNotSupported // return all we support
allChains, err := s.networkManager.GetAll()
if err != nil {
return nil, fmt.Errorf("failed to get all chains: %w", err)
}
for _, chain := range allChains {
chains = append(chains, chain.ChainID)
eipChains = append(eipChains, fmt.Sprintf("%s:%d", SupportedEip155Namespace, chain.ChainID))
}
} else {
var proposedChains []string
for key, ns := range proposal.Params.RequiredNamespaces {
if !strings.Contains(key, SupportedEip155Namespace) {
log.Warn("Some namespaces are not supported; wanted: ", key, "; supported: ", SupportedEip155Namespace)
return nil, ErrorNamespaceNotSupported
}
if strings.Contains(key, ":") {
proposedChains = append(proposedChains, key)
} else {
proposedChains = append(proposedChains, ns.Chains...)
}
}
chains, eipChains = sessionProposalToSupportedChain(proposedChains, func(chainID uint64) bool {
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() activeAccounts, err := s.accountsDB.GetActiveAccounts()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get active accounts: %w", err) return nil, fmt.Errorf("failed to get active accounts: %w", err)
} }
// Filter out non-own accounts allWalletAccountsReadyForTransaction := make([]*accounts.Account, 0, 1)
usableAccounts := make([]*accounts.Account, 0, 1)
for _, acc := range activeAccounts { for _, acc := range activeAccounts {
if !acc.IsWalletAccountReadyForTransaction() { if !acc.IsWalletAccountReadyForTransaction() {
continue continue
} }
usableAccounts = append(usableAccounts, acc) allWalletAccountsReadyForTransaction = append(allWalletAccountsReadyForTransaction, acc)
} }
addresses := activeToOwnedAccounts(usableAccounts) result := &PairSessionResponse{
namespace.Accounts = caip10Accounts(addresses, chains) SessionProposal: proposal,
SupportedNamespaces: map[string]Namespace{
SupportedEip155Namespace: Namespace{
Methods: []string{params.SendTransactionMethodName,
params.PersonalSignMethodName,
},
Events: []string{"accountsChanged", "chainChanged"},
Chains: eipChains,
Accounts: caip10Accounts(allWalletAccountsReadyForTransaction, chains),
},
},
}
// TODO #12434: respond async // TODO #12434: respond async
return &PairSessionResponse{ return result, nil
SessionProposal: proposal,
SupportedNamespaces: Namespaces{
Eip155: namespace,
},
}, nil
} }
func (s *Service) RecordSuccessfulPairing(proposal SessionProposal) error { func (s *Service) RecordSuccessfulPairing(proposal SessionProposal) error {

View File

@ -3,7 +3,9 @@ package walletconnect
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strconv" "strconv"
"strings"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
@ -12,11 +14,20 @@ import (
"github.com/status-im/status-go/services/wallet/walletevent" "github.com/status-im/status-go/services/wallet/walletevent"
) )
const ProposeUserPairEvent = walletevent.EventType("WalletConnectProposeUserPair") const (
SupportedEip155Namespace = "eip155"
var ErrorChainsNotSupported = errors.New("chains not supported") ProposeUserPairEvent = walletevent.EventType("WalletConnectProposeUserPair")
var ErrorInvalidParamsCount = errors.New("invalid params count") )
var ErrorMethodNotSupported = errors.New("method not supported")
var (
ErrorInvalidSessionProposal = errors.New("invalid session proposal")
ErrorNamespaceNotSupported = errors.New("namespace not supported")
ErrorChainsNotSupported = errors.New("chains not supported")
ErrorInvalidParamsCount = errors.New("invalid params count")
ErrorInvalidAddressMsgIndex = errors.New("invalid address and/or msg index (must be 0 or 1)")
ErrorMethodNotSupported = errors.New("method not supported")
)
type Topic string type Topic string
@ -40,11 +51,6 @@ type Proposer struct {
Metadata Metadata `json:"metadata"` Metadata Metadata `json:"metadata"`
} }
type Namespaces struct {
Eip155 Namespace `json:"eip155"`
// We ignore non ethereum namespaces
}
type Verified struct { type Verified struct {
VerifyURL string `json:"verifyUrl"` VerifyURL string `json:"verifyUrl"`
Validation string `json:"validation"` Validation string `json:"validation"`
@ -57,13 +63,13 @@ type VerifyContext struct {
} }
type Params struct { type Params struct {
ID int64 `json:"id"` ID int64 `json:"id"`
PairingTopic Topic `json:"pairingTopic"` PairingTopic Topic `json:"pairingTopic"`
Expiry int64 `json:"expiry"` Expiry int64 `json:"expiry"`
RequiredNamespaces Namespaces `json:"requiredNamespaces"` RequiredNamespaces map[string]Namespace `json:"requiredNamespaces"`
OptionalNamespaces Namespaces `json:"optionalNamespaces"` OptionalNamespaces map[string]Namespace `json:"optionalNamespaces"`
Proposer Proposer `json:"proposer"` Proposer Proposer `json:"proposer"`
Verify VerifyContext `json:"verifyContext"` Verify VerifyContext `json:"verifyContext"`
} }
type SessionProposal struct { type SessionProposal struct {
@ -72,8 +78,8 @@ type SessionProposal struct {
} }
type PairSessionResponse struct { type PairSessionResponse struct {
SessionProposal SessionProposal `json:"sessionProposal"` SessionProposal SessionProposal `json:"sessionProposal"`
SupportedNamespaces Namespaces `json:"supportedNamespaces"` SupportedNamespaces map[string]Namespace `json:"supportedNamespaces"`
} }
type RequestParams struct { type RequestParams struct {
@ -105,11 +111,67 @@ type SessionRequestResponse struct {
SignedMessage interface{} `json:"signedMessage,omitempty"` SignedMessage interface{} `json:"signedMessage,omitempty"`
} }
// Valid namespace
func (n *Namespace) Valid(namespaceName string, chainID *uint64) bool {
if chainID == nil {
if len(n.Chains) == 0 {
log.Warn("namespace doesn't refer to any chain")
return false
}
for _, caip2Str := range n.Chains {
resolvedNamespaceName, _, err := parseCaip2ChainID(caip2Str)
if err != nil {
log.Warn("namespace chain not in caip2 format", "chain", caip2Str, "error", err)
return false
}
if resolvedNamespaceName != namespaceName {
log.Warn("namespace name doesn't match", "namespace", namespaceName, "chain", caip2Str)
return false
}
}
}
return true
}
// Valid params
func (p *Params) Valid() bool {
for key, ns := range p.RequiredNamespaces {
var chainID *uint64
if strings.Contains(key, ":") {
resolvedNamespaceName, cID, err := parseCaip2ChainID(key)
if err != nil {
log.Warn("params validation failed CAIP-2", "str", key, "error", err)
return false
}
key = resolvedNamespaceName
chainID = &cID
}
if !isValidNamespaceName(key) {
log.Warn("invalid namespace name", "namespace", key)
return false
}
if !ns.Valid(key, chainID) {
return false
}
}
return true
}
// Valid session propsal
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#controller-side-validation-of-incoming-proposal-namespaces-wallet
func (p *SessionProposal) Valid() bool {
return p.Params.Valid()
}
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)
for _, caip2Str := range caipChains { for _, caip2Str := range caipChains {
chainID, err := parseCaip2ChainID(caip2Str) _, chainID, err := parseCaip2ChainID(caip2Str)
if err != nil { if err != nil {
log.Warn("Failed parsing CAIP-2", "str", caip2Str, "error", err) log.Warn("Failed parsing CAIP-2", "str", caip2Str, "error", err)
continue continue
@ -125,22 +187,12 @@ func sessionProposalToSupportedChain(caipChains []string, supportsChain func(uin
return return
} }
func activeToOwnedAccounts(activeAccounts []*accounts.Account) []types.Address { func caip10Accounts(accounts []*accounts.Account, chains []uint64) []string {
addresses := make([]types.Address, 0, 1) addresses := make([]string, 0, len(accounts)*len(chains))
for _, account := range activeAccounts { for _, acc := range accounts {
if account.Type != accounts.AccountTypeWatch { for _, chainID := range chains {
addresses = append(addresses, account.Address) addresses = append(addresses, fmt.Sprintf("%s:%s:%s", SupportedEip155Namespace, strconv.FormatUint(chainID, 10), acc.Address.Hex()))
} }
} }
return addresses return addresses
} }
func caip10Accounts(addresses []types.Address, chains []uint64) []string {
accounts := make([]string, 0, len(addresses)*len(chains))
for _, address := range addresses {
for _, chainID := range chains {
accounts = append(accounts, "eip155:"+strconv.FormatUint(chainID, 10)+":"+address.Hex())
}
}
return accounts
}

View File

@ -4,10 +4,194 @@ import (
"reflect" "reflect"
"testing" "testing"
"encoding/json"
"github.com/status-im/status-go/eth-node/types" "github.com/status-im/status-go/eth-node/types"
"github.com/status-im/status-go/multiaccounts/accounts" "github.com/status-im/status-go/multiaccounts/accounts"
"github.com/stretchr/testify/assert"
) )
func Test_sessionProposalValidity(t *testing.T) {
tests := []struct {
name string
sessionProposalJSON string
expectedValidity bool
}{
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#11-proposal-namespaces-does-not-include-an-optional-namespace
{
name: "proposal-namespaces-does-not-include-an-optional-namespace",
sessionProposalJSON: `{
"params": {
"requiredNamespaces": {
"eip155:10": {
"methods": ["personal_sign"],
"events": ["accountsChanged", "chainChanged"]
}
}
}
}`,
expectedValidity: true,
},
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#12-proposal-namespaces-must-not-have-chains-empty
{
name: "proposal-namespaces-must-not-have-chains-empty",
sessionProposalJSON: `{
"params": {
"requiredNamespaces": {
"cosmos": {
"chains": [],
"methods": ["cosmos_signDirect"],
"events": ["someCosmosEvent"]
}
}
}
}`,
expectedValidity: false,
},
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index
{
name: "chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index",
sessionProposalJSON: `{
"params": {
"requiredNamespaces": {
"eip155": {
"chains": ["eip155:1", "eip155:137"],
"methods": ["eth_sendTransaction", "eth_signTransaction", "eth_sign"],
"events": ["accountsChanged", "chainChanged"]
},
"eip155:10": {
"methods": ["personal_sign"],
"events": ["accountsChanged", "chainChanged"]
}
}
}
}`,
expectedValidity: true,
},
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#14-chains-must-be-caip-2-compliant
{
name: "chains-must-be-caip-2-compliant",
sessionProposalJSON: `{
"params": {
"requiredNamespaces": {
"eip155": {
"chains": ["42"],
"methods": ["eth_sign"],
"events": ["accountsChanged"]
}
}
}
}`,
expectedValidity: false,
},
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#15-proposal-namespace-methods-and-events-may-be-empty
{
name: "proposal-namespace-methods-and-events-may-be-empty",
sessionProposalJSON: `{
"params": {
"requiredNamespaces": {
"eip155": {
"chains": ["eip155:1"],
"methods": [],
"events": []
}
}
}
}`,
expectedValidity: true,
},
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#16-all-chains-in-the-namespace-must-contain-the-namespace-prefix
{
name: "all-chains-in-the-namespace-must-contain-the-namespace-prefix",
sessionProposalJSON: `{
"params": {
"requiredNamespaces": {
"eip155": {
"chains": ["eip155:1", "eip155:137", "cosmos:cosmoshub-4"],
"methods": ["eth_sendTransaction"],
"events": ["accountsChanged", "chainChanged"]
}
},
"optionalNamespaces": {
"eip155:42161": {
"methods": ["personal_sign"],
"events": ["accountsChanged", "chainChanged"]
}
}
}
}`,
expectedValidity: false,
},
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#17-namespace-key-must-comply-with-caip-2-specification
{
name: "namespace-key-must-comply-with-caip-2-specification",
sessionProposalJSON: `{
"params": {
"requiredNamespaces": {
"": {
"chains": [":1"],
"methods": ["personalSign"],
"events": []
},
"**": {
"chains": ["**:1"],
"methods": ["personalSign"],
"events": []
}
}
}
}`,
expectedValidity: false,
},
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#18-all-namespaces-must-be-valid
{
name: "all-namespaces-must-be-valid",
sessionProposalJSON: `{
"params": {
"requiredNamespaces": {
"eip155": {
"chains": ["eip155:1"],
"methods": ["personalSign"],
"events": []
},
"cosmos": {
"chains": [],
"methods": [],
"events": []
}
}
}
}`,
expectedValidity: false,
},
// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces#19-proposal-namespaces-may-be-empty
{
name: "proposal-namespaces-may-be-empty",
sessionProposalJSON: `{
"params": {
"requiredNamespaces": {}
}
}`,
expectedValidity: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var sessionProposal SessionProposal
err := json.Unmarshal([]byte(tt.sessionProposalJSON), &sessionProposal)
assert.NoError(t, err)
if tt.expectedValidity {
assert.True(t, sessionProposal.Valid())
} else {
assert.False(t, sessionProposal.Valid())
}
})
}
}
func Test_sessionProposalToSupportedChain(t *testing.T) { func Test_sessionProposalToSupportedChain(t *testing.T) {
type args struct { type args struct {
chains []string chains []string
@ -66,52 +250,10 @@ func Test_sessionProposalToSupportedChain(t *testing.T) {
} }
} }
func Test_activeToOwnedAccounts(t *testing.T) {
type args struct {
activeAccounts []*accounts.Account
}
tests := []struct {
name string
args args
want []types.Address
}{
{
name: "filter_out_watch_accounts",
args: args{
activeAccounts: []*accounts.Account{
{
Address: types.HexToAddress("0x1"),
Type: accounts.AccountTypeWatch,
},
{
Address: types.HexToAddress("0x2"),
Type: accounts.AccountTypeSeed,
},
{
Address: types.HexToAddress("0x3"),
Type: accounts.AccountTypeSeed,
},
},
},
want: []types.Address{
types.HexToAddress("0x2"),
types.HexToAddress("0x3"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := activeToOwnedAccounts(tt.args.activeAccounts); !reflect.DeepEqual(got, tt.want) {
t.Errorf("activeToOwnedAccounts() = %v, want %v", got, tt.want)
}
})
}
}
func Test_caip10Accounts(t *testing.T) { func Test_caip10Accounts(t *testing.T) {
type args struct { type args struct {
addresses []types.Address accounts []*accounts.Account
chains []uint64 chains []uint64
} }
tests := []struct { tests := []struct {
name string name string
@ -121,9 +263,15 @@ func Test_caip10Accounts(t *testing.T) {
{ {
name: "generate_caip10_accounts", name: "generate_caip10_accounts",
args: args{ args: args{
addresses: []types.Address{ accounts: []*accounts.Account{
types.HexToAddress("0x1"), {
types.HexToAddress("0x2"), Address: types.HexToAddress("0x1"),
Type: accounts.AccountTypeWatch,
},
{
Address: types.HexToAddress("0x2"),
Type: accounts.AccountTypeSeed,
},
}, },
chains: []uint64{1, 2}, chains: []uint64{1, 2},
}, },
@ -137,17 +285,23 @@ func Test_caip10Accounts(t *testing.T) {
{ {
name: "empty_addresses", name: "empty_addresses",
args: args{ args: args{
addresses: []types.Address{}, accounts: []*accounts.Account{},
chains: []uint64{1, 2}, chains: []uint64{1, 2},
}, },
want: []string{}, want: []string{},
}, },
{ {
name: "empty_chains", name: "empty_chains",
args: args{ args: args{
addresses: []types.Address{ accounts: []*accounts.Account{
types.HexToAddress("0x1"), {
types.HexToAddress("0x2"), Address: types.HexToAddress("0x1"),
Type: accounts.AccountTypeWatch,
},
{
Address: types.HexToAddress("0x2"),
Type: accounts.AccountTypeSeed,
},
}, },
chains: []uint64{}, chains: []uint64{},
}, },
@ -156,7 +310,7 @@ func Test_caip10Accounts(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := caip10Accounts(tt.args.addresses, tt.args.chains); !reflect.DeepEqual(got, tt.want) { if got := caip10Accounts(tt.args.accounts, tt.args.chains); !reflect.DeepEqual(got, tt.want) {
t.Errorf("caip10Accounts() = %v, want %v", got, tt.want) t.Errorf("caip10Accounts() = %v, want %v", got, tt.want)
} }
}) })