status-go/services/wallet/walletconnect/walletconnect_test.go
Stefan dc62171219 fix(dapps)_: don't require chain ID for signing typed data v4
The reused implementation from signing typed data V1 was used
in case of signing typed data V4. This implementation required
chain ID to be present in the typed data. This change fixes
the issue by making chainID optional for signing typed data V4.
2024-07-19 09:00:01 +02:00

610 lines
15 KiB
Go

package walletconnect
import (
"crypto/ecdsa"
"encoding/json"
"reflect"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/status-im/status-go/eth-node/crypto"
"github.com/status-im/status-go/eth-node/types"
"github.com/status-im/status-go/multiaccounts/accounts"
"github.com/status-im/status-go/params"
)
func getSessionJSONFor(chains []int, expiry int) string {
chainsStr := "["
for i, chain := range chains {
chainsStr += `"eip155:` + strconv.Itoa(chain) + `"`
if i != len(chains)-1 {
chainsStr += ","
}
}
chainsStr += "]"
expiryStr := strconv.Itoa(expiry)
return `{
"expiry": ` + expiryStr + `,
"namespaces": {
"eip155": {
"accounts": [
"eip155:1:0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240",
"eip155:10:0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240",
"eip155:42161:0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240"
],
"chains": ` + chainsStr + `,
"events": [
"accountsChanged",
"chainChanged"
],
"methods": [
"eth_sendTransaction",
"personal_sign"
]
}
},
"optionalNamespaces": {
"eip155": {
"chains": [],
"events": [],
"methods": [],
"rpcMap": {}
}
},
"pairingTopic": "50fba141cdb5c015493c2907c46bacf9f7cbd7c8e3d4e97df891f18dddcff69c",
"peer": {
"metadata": {
"description": "Test Dapp Description",
"icons": [ "https://test.org/test.png"],
"name": "Test Dapp",
"url": "https://dapp.test.org"
},
"publicKey": "1234567890aeb6081cabed26faf48919162fd70cc66d639f118a60507ae0463d"
},
"relay": { "protocol": "irn"},
"requiredNamespaces": {
"eip155": {
"chains": [
"eip155:1"
],
"events": [
"chainChanged",
"accountsChanged"
],
"methods": [
"eth_sendTransaction",
"personal_sign"
],
"rpcMap": {
"1": "https://mainnet.infura.io/v3/099fc58e0de9451d80b18d7c74caa7c1"
}
}
},
"self": {
"metadata": {
"description": "Test Wallet Description",
"icons": [
"https://wallet.test.org/test.svg"
],
"name": "Test Wallet",
"url": "http://localhost"
},
"publicKey": "da4a87d5f0f54951afe870ebf020cf03f8a3522fbd219398c3fa159a37e16d54"
},
"topic": "e39e1f435a46b5ee6b31484d1751cfbc35be1275653af2ea340974a7592f1a19"
}`
}
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)
validRes := sessionProposal.ValidateProposal()
if tt.expectedValidity {
assert.True(t, validRes)
} else {
assert.False(t, validRes)
}
})
}
}
func Test_supportedChainInSession(t *testing.T) {
type args struct {
sessionProposal Session
}
tests := []struct {
name string
args args
expectedChains []uint64
}{
{
name: "supported_chain",
args: args{
sessionProposal: Session{
Namespaces: map[string]Namespace{
"eip155": {
Chains: []string{"eip155:1", "eip155:2", "eip155:3", "eip155:4", "eip155:5"},
},
},
},
},
expectedChains: []uint64{1, 2, 3, 4, 5},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotChains := supportedChainsInSession(tt.args.sessionProposal)
if !reflect.DeepEqual(gotChains, tt.expectedChains) {
t.Errorf("supportedChainInSessionProposal() gotChains = %v, want %v", gotChains, tt.expectedChains)
}
})
}
}
func Test_caip10Accounts(t *testing.T) {
type args struct {
accounts []*accounts.Account
chains []uint64
}
tests := []struct {
name string
args args
want []string
}{
{
name: "generate_caip10_accounts",
args: args{
accounts: []*accounts.Account{
{
Address: types.HexToAddress("0x1"),
Type: accounts.AccountTypeWatch,
},
{
Address: types.HexToAddress("0x2"),
Type: accounts.AccountTypeSeed,
},
},
chains: []uint64{1, 2},
},
want: []string{
"eip155:1:0x0000000000000000000000000000000000000001",
"eip155:2:0x0000000000000000000000000000000000000001",
"eip155:1:0x0000000000000000000000000000000000000002",
"eip155:2:0x0000000000000000000000000000000000000002",
},
},
{
name: "empty_addresses",
args: args{
accounts: []*accounts.Account{},
chains: []uint64{1, 2},
},
want: []string{},
},
{
name: "empty_chains",
args: args{
accounts: []*accounts.Account{
{
Address: types.HexToAddress("0x1"),
Type: accounts.AccountTypeWatch,
},
{
Address: types.HexToAddress("0x2"),
Type: accounts.AccountTypeSeed,
},
},
chains: []uint64{},
},
want: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := caip10Accounts(tt.args.accounts, tt.args.chains); !reflect.DeepEqual(got, tt.want) {
t.Errorf("caip10Accounts() = %v, want %v", got, tt.want)
}
})
}
}
// Test_AddSession validates that the new added session is active (not expired and not disconnected)
func Test_AddSession(t *testing.T) {
db, close := SetupTestDB(t)
defer close()
// Add session for testnet
expiry := 1716581732
chainID := 11155111
sessionJSON := getSessionJSONFor([]int{chainID}, expiry)
networks := []params.Network{
{ChainID: 1, IsTest: false},
{ChainID: uint64(chainID), IsTest: true},
}
timestampBeforeAddSession := time.Now().Unix()
err := AddSession(db, networks, sessionJSON)
assert.NoError(t, err)
// Validate that session was written correctly to the database
sessions, err := GetSessions(db)
assert.NoError(t, err)
assert.Equal(t, 1, len(sessions))
sessJSONObj := map[string]interface{}{}
err = json.Unmarshal([]byte(sessionJSON), &sessJSONObj)
assert.NoError(t, err)
assert.Equal(t, false, sessions[0].Disconnected)
assert.Equal(t, sessionJSON, sessions[0].SessionJSON)
assert.Equal(t, int64(expiry), sessions[0].Expiry)
assert.GreaterOrEqual(t, sessions[0].CreatedTimestamp, timestampBeforeAddSession)
assert.Equal(t, sessJSONObj["pairingTopic"], string(sessions[0].PairingTopic))
assert.Equal(t, sessJSONObj["topic"], string(sessions[0].Topic))
assert.Equal(t, true, sessions[0].TestChains)
metadata := sessJSONObj["peer"].(map[string]interface{})["metadata"].(map[string]interface{})
assert.Equal(t, metadata["url"], sessions[0].URL)
assert.Equal(t, metadata["name"], sessions[0].Name)
assert.Equal(t, metadata["icons"].([]interface{})[0], sessions[0].IconURL)
dapps, err := GetActiveDapps(db, int64(expiry-1), true)
assert.NoError(t, err)
assert.Equal(t, 1, len(dapps))
assert.Equal(t, sessions[0].URL, dapps[0].URL)
assert.Equal(t, sessions[0].Name, dapps[0].Name)
assert.Equal(t, sessions[0].IconURL, dapps[0].IconURL)
}
type typedDataParams struct {
chainID int
skipField bool
excludeChainID bool
wrongContractType bool
}
func generateTypedDataJson(p typedDataParams) string {
optionalKeyValueField := ""
if !p.skipField {
if p.wrongContractType {
optionalKeyValueField = `,"verifyingContract": true`
} else {
optionalKeyValueField = `,"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"`
}
}
chainIDSchemeEntry := ""
chainIDDataEntry := ""
if !p.excludeChainID {
chainIDSchemeEntry = `{"name": "chainId", "type": "uint256"},`
chainIDDataEntry = `,"chainId": ` + strconv.Itoa(p.chainID)
}
typedData := `{
"types": {
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
` + chainIDSchemeEntry + `
{"name": "verifyingContract", "type": "address"}
],
"Person": [
{"name": "name", "type": "string"},
{"name": "wallet", "type": "address"}
],
"Mail": [
{"name": "from", "type": "Person"},
{"name": "to", "type": "Person"},
{"name": "contents", "type": "string"}
]
},
"primaryType": "Mail",
"domain": {
"name": "Ether Mail",
"version": "1"
` + chainIDDataEntry + `
` + optionalKeyValueField + `
},
"message": {
"from": {
"name": "Cow",
"wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"
},
"to": {
"name": "Bob",
"wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"
},
"contents": "Hello, Bob!"
}
}`
return typedData
}
func TestSafeSignTypedDataForDApps(t *testing.T) {
// 0x4f1B9Ee595bF612480ADAF623Ec583f623ae802d
privateKey, err := crypto.HexToECDSA("efe79ae971aa8bb612de9de7c65b9224ab1b6a69e6ec733ec92110f100c7244a")
require.NoError(t, err)
type args struct {
typedJson string
privateKey *ecdsa.PrivateKey
chainID uint64
legacy bool
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "sign_typed_data",
args: args{
typedJson: generateTypedDataJson(typedDataParams{
chainID: 1,
}),
privateKey: privateKey,
chainID: 1,
legacy: false,
},
wantErr: false,
},
{
name: "sign_typed_data_legacy",
args: args{
typedJson: generateTypedDataJson(typedDataParams{
chainID: 1,
}),
privateKey: privateKey,
chainID: 1,
legacy: true,
},
wantErr: false,
},
{
name: "sign_typed_data_invalid_json",
args: args{
typedJson: generateTypedDataJson(typedDataParams{
chainID: 1,
wrongContractType: true,
}),
privateKey: privateKey,
chainID: 1,
legacy: false,
},
wantErr: true,
},
{
name: "sign_typed_data_invalid_json_legacy",
args: args{
typedJson: `{"invalid": "json"`,
privateKey: privateKey,
chainID: 1,
legacy: true,
},
wantErr: true,
},
{
name: "sign_typed_data_invalid_chain_id",
args: args{
typedJson: generateTypedDataJson(typedDataParams{
chainID: 1,
}),
privateKey: privateKey,
chainID: 2,
legacy: false,
},
wantErr: true,
},
{
name: "sign_typed_data_missing_field",
args: args{
typedJson: generateTypedDataJson(typedDataParams{
chainID: 1,
skipField: true,
}),
privateKey: privateKey,
chainID: 1,
legacy: false,
},
wantErr: true,
},
{
name: "sign_typed_data_exclude_chain_id",
args: args{
typedJson: generateTypedDataJson(typedDataParams{
chainID: 1,
excludeChainID: true,
}),
privateKey: privateKey,
chainID: 1,
legacy: false,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SafeSignTypedDataForDApps(tt.args.typedJson, tt.args.privateKey, tt.args.chainID, tt.args.legacy)
if (err != nil) != tt.wantErr {
t.Errorf("SafeSignTypedDataForDApps() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
require.NotEmpty(t, got)
require.Len(t, got, 65)
}
})
}
}