From 382a6aec8111a4e2e3e9a7c41438475f6920134c Mon Sep 17 00:00:00 2001 From: Austin Chandra Date: Tue, 11 Apr 2023 09:03:18 -0700 Subject: [PATCH] Migrate updated EIP-712 algorithm from Evmos (#1746) * Migrate updated EIP-712 algorithm from Evmos * Update licenses for Ethermint * update gomod2nix and go mod tidy * Move ante test suite execution to ante_test --------- Co-authored-by: Freddy Caceres --- app/ante/ante_test.go | 24 +- app/ante/eip712.go | 2 +- app/ante/utils_test.go | 143 +++----- crypto/ethsecp256k1/ethsecp256k1.go | 12 +- ethereum/eip712/domain.go | 34 ++ ethereum/eip712/eip712.go | 465 +----------------------- ethereum/eip712/eip712_fuzzer_test.go | 193 ++++++++++ ethereum/eip712/eip712_legacy.go | 452 ++++++++++++++++++++++++ ethereum/eip712/eip712_test.go | 486 ++++++++++++++++++-------- ethereum/eip712/encoding.go | 75 +--- ethereum/eip712/encoding_legacy.go | 280 +++++++++++++++ ethereum/eip712/message.go | 162 +++++++++ ethereum/eip712/preprocess.go | 98 ------ ethereum/eip712/preprocess_test.go | 222 ------------ ethereum/eip712/types.go | 404 +++++++++++++++++++++ go.mod | 4 + go.sum | 7 + gomod2nix.toml | 44 ++- testutil/tx/eip712.go | 40 ++- 19 files changed, 2028 insertions(+), 1119 deletions(-) create mode 100644 ethereum/eip712/domain.go create mode 100644 ethereum/eip712/eip712_fuzzer_test.go create mode 100644 ethereum/eip712/eip712_legacy.go create mode 100644 ethereum/eip712/encoding_legacy.go create mode 100644 ethereum/eip712/message.go delete mode 100644 ethereum/eip712/preprocess.go delete mode 100644 ethereum/eip712/preprocess_test.go create mode 100644 ethereum/eip712/types.go diff --git a/app/ante/ante_test.go b/app/ante/ante_test.go index 369b29a3f2..5492450f37 100644 --- a/app/ante/ante_test.go +++ b/app/ante/ante_test.go @@ -5,10 +5,12 @@ import ( "fmt" "math/big" "strings" + "testing" "time" sdkmath "cosmossdk.io/math" kmultisig "github.com/cosmos/cosmos-sdk/crypto/keys/multisig" + "github.com/stretchr/testify/suite" "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" "github.com/cosmos/cosmos-sdk/crypto/keys/secp256r1" @@ -34,6 +36,26 @@ import ( govtypes "github.com/cosmos/cosmos-sdk/x/gov/types/v1beta1" ) +func TestAnteTestSuite(t *testing.T) { + suite.Run(t, &AnteTestSuite{ + enableLondonHF: true, + }) + + // Re-run the tests with EIP-712 Legacy encodings to ensure backwards compatibility. + // LegacyEIP712Extension should not be run with current TypedData encodings, since they are not compatible. + suite.Run(t, &AnteTestSuite{ + enableLondonHF: true, + useLegacyEIP712Extension: true, + useLegacyEIP712TypedData: true, + }) + + suite.Run(t, &AnteTestSuite{ + enableLondonHF: true, + useLegacyEIP712Extension: false, + useLegacyEIP712TypedData: true, + }) +} + func (suite AnteTestSuite) TestAnteHandler() { var acc authtypes.AccountI addr, privKey := tests.NewAddrKey() @@ -387,7 +409,7 @@ func (suite AnteTestSuite) TestAnteHandler() { from, grantee, &banktypes.SendAuthorization{SpendLimit: gasAmount}, &expiresAt, ) suite.Require().NoError(err) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, privKey, "ethermint_9000-1", gas, gasAmount, msg).GetTx() + return suite.CreateTestEIP712SingleMessageTxBuilder(privKey, "ethermint_9000-1", gas, gasAmount, msg).GetTx() }, false, false, true, }, diff --git a/app/ante/eip712.go b/app/ante/eip712.go index f5a9bca72c..2bb55a37f4 100644 --- a/app/ante/eip712.go +++ b/app/ante/eip712.go @@ -266,7 +266,7 @@ func VerifySignature( FeePayer: feePayer, } - typedData, err := eip712.WrapTxToTypedData(ethermintCodec, extOpt.TypedDataChainID, msgs[0], txBytes, feeDelegation) + typedData, err := eip712.LegacyWrapTxToTypedData(ethermintCodec, extOpt.TypedDataChainID, msgs[0], txBytes, feeDelegation) if err != nil { return errorsmod.Wrap(err, "failed to create EIP-712 typed data from tx") } diff --git a/app/ante/utils_test.go b/app/ante/utils_test.go index 8db74635a4..a5fabcd9ac 100644 --- a/app/ante/utils_test.go +++ b/app/ante/utils_test.go @@ -5,7 +5,6 @@ import ( "fmt" "math" "math/big" - "testing" "time" sdkmath "cosmossdk.io/math" @@ -15,11 +14,9 @@ import ( "github.com/cosmos/cosmos-sdk/x/auth/migrations/legacytx" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/signer/core/apitypes" "github.com/evmos/ethermint/ethereum/eip712" "github.com/evmos/ethermint/testutil" - "github.com/evmos/ethermint/types" + utiltx "github.com/evmos/ethermint/testutil/tx" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -40,7 +37,6 @@ import ( authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" authz "github.com/cosmos/cosmos-sdk/x/authz" - cryptocodec "github.com/evmos/ethermint/crypto/codec" "github.com/evmos/ethermint/crypto/ethsecp256k1" "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" @@ -63,15 +59,17 @@ import ( type AnteTestSuite struct { suite.Suite - ctx sdk.Context - app *app.EthermintApp - clientCtx client.Context - anteHandler sdk.AnteHandler - priv cryptotypes.PrivKey - ethSigner ethtypes.Signer - enableFeemarket bool - enableLondonHF bool - evmParamsOption func(*evmtypes.Params) + ctx sdk.Context + app *app.EthermintApp + clientCtx client.Context + anteHandler sdk.AnteHandler + priv cryptotypes.PrivKey + ethSigner ethtypes.Signer + enableFeemarket bool + enableLondonHF bool + evmParamsOption func(*evmtypes.Params) + useLegacyEIP712Extension bool + useLegacyEIP712TypedData bool } const TestGasLimit uint64 = 100000 @@ -169,12 +167,6 @@ func (suite *AnteTestSuite) SetupTest() { suite.Require().NoError(err) } -func TestAnteTestSuite(t *testing.T) { - suite.Run(t, &AnteTestSuite{ - enableLondonHF: true, - }) -} - func (s *AnteTestSuite) BuildTestEthTx( from common.Address, to common.Address, @@ -303,7 +295,7 @@ func (suite *AnteTestSuite) CreateTestEIP712TxBuilderMsgSend(from sdk.AccAddress // Build MsgSend recipient := sdk.AccAddress(common.Address{}.Bytes()) msgSend := banktypes.NewMsgSend(from, recipient, sdk.NewCoins(sdk.NewCoin(evmtypes.DefaultEVMDenom, sdkmath.NewInt(1)))) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgSend) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgSend) } func (suite *AnteTestSuite) CreateTestEIP712TxBuilderMsgDelegate(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { @@ -311,7 +303,7 @@ func (suite *AnteTestSuite) CreateTestEIP712TxBuilderMsgDelegate(from sdk.AccAdd valEthAddr := tests.GenerateAddress() valAddr := sdk.ValAddress(valEthAddr.Bytes()) msgSend := stakingtypes.NewMsgDelegate(from, valAddr, sdk.NewCoin(evmtypes.DefaultEVMDenom, sdkmath.NewInt(20))) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgSend) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgSend) } func (suite *AnteTestSuite) CreateTestEIP712MsgCreateValidator(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { @@ -327,7 +319,7 @@ func (suite *AnteTestSuite) CreateTestEIP712MsgCreateValidator(from sdk.AccAddre sdk.OneInt(), ) suite.Require().NoError(err) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgCreate) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgCreate) } func (suite *AnteTestSuite) CreateTestEIP712MsgCreateValidator2(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { @@ -344,7 +336,7 @@ func (suite *AnteTestSuite) CreateTestEIP712MsgCreateValidator2(from sdk.AccAddr sdk.OneInt(), ) suite.Require().NoError(err) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgCreate) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgCreate) } func (suite *AnteTestSuite) CreateTestEIP712SubmitProposal(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins, deposit sdk.Coins) client.TxBuilder { @@ -352,7 +344,7 @@ func (suite *AnteTestSuite) CreateTestEIP712SubmitProposal(from sdk.AccAddress, suite.Require().True(ok) msgSubmit, err := govtypes.NewMsgSubmitProposal(proposal, deposit, from) suite.Require().NoError(err) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgSubmit) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgSubmit) } func (suite *AnteTestSuite) CreateTestEIP712GrantAllowance(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { @@ -366,7 +358,7 @@ func (suite *AnteTestSuite) CreateTestEIP712GrantAllowance(from sdk.AccAddress, grantedAddr := suite.app.AccountKeeper.NewAccountWithAddress(suite.ctx, granted.Bytes()) msgGrant, err := feegrant.NewMsgGrantAllowance(basic, from, grantedAddr.GetAddress()) suite.Require().NoError(err) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgGrant) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgGrant) } func (suite *AnteTestSuite) CreateTestEIP712MsgEditValidator(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { @@ -377,7 +369,7 @@ func (suite *AnteTestSuite) CreateTestEIP712MsgEditValidator(from sdk.AccAddress nil, nil, ) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgEdit) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgEdit) } func (suite *AnteTestSuite) CreateTestEIP712MsgSubmitEvidence(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { @@ -390,12 +382,12 @@ func (suite *AnteTestSuite) CreateTestEIP712MsgSubmitEvidence(from sdk.AccAddres }) suite.Require().NoError(err) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgEvidence) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgEvidence) } func (suite *AnteTestSuite) CreateTestEIP712MsgVoteV1(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { msgVote := govtypesv1.NewMsgVote(from, 1, govtypesv1.VoteOption_VOTE_OPTION_YES, "") - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgVote) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgVote) } func (suite *AnteTestSuite) CreateTestEIP712SubmitProposalV1(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { @@ -434,20 +426,20 @@ func (suite *AnteTestSuite) CreateTestEIP712SubmitProposalV1(from sdk.AccAddress suite.Require().NoError(err) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, msgProposal) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, msgProposal) } func (suite *AnteTestSuite) CreateTestEIP712MsgExec(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { recipient := sdk.AccAddress(common.Address{}.Bytes()) msgSend := banktypes.NewMsgSend(from, recipient, sdk.NewCoins(sdk.NewCoin(evmtypes.DefaultEVMDenom, sdkmath.NewInt(1)))) msgExec := authz.NewMsgExec(from, []sdk.Msg{msgSend}) - return suite.CreateTestEIP712SingleMessageTxBuilder(from, priv, chainId, gas, gasAmount, &msgExec) + return suite.CreateTestEIP712SingleMessageTxBuilder(priv, chainId, gas, gasAmount, &msgExec) } func (suite *AnteTestSuite) CreateTestEIP712MultipleMsgSend(from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins) client.TxBuilder { recipient := sdk.AccAddress(common.Address{}.Bytes()) msgSend := banktypes.NewMsgSend(from, recipient, sdk.NewCoins(sdk.NewCoin(evmtypes.DefaultEVMDenom, sdkmath.NewInt(1)))) - return suite.CreateTestEIP712CosmosTxBuilder(from, priv, chainId, gas, gasAmount, []sdk.Msg{msgSend, msgSend, msgSend}) + return suite.CreateTestEIP712CosmosTxBuilder(priv, chainId, gas, gasAmount, []sdk.Msg{msgSend, msgSend, msgSend}) } // Fails @@ -455,7 +447,7 @@ func (suite *AnteTestSuite) CreateTestEIP712MultipleSignerMsgs(from sdk.AccAddre recipient := sdk.AccAddress(common.Address{}.Bytes()) msgSend1 := banktypes.NewMsgSend(from, recipient, sdk.NewCoins(sdk.NewCoin(evmtypes.DefaultEVMDenom, sdkmath.NewInt(1)))) msgSend2 := banktypes.NewMsgSend(recipient, from, sdk.NewCoins(sdk.NewCoin(evmtypes.DefaultEVMDenom, sdkmath.NewInt(1)))) - return suite.CreateTestEIP712CosmosTxBuilder(from, priv, chainId, gas, gasAmount, []sdk.Msg{msgSend1, msgSend2}) + return suite.CreateTestEIP712CosmosTxBuilder(priv, chainId, gas, gasAmount, []sdk.Msg{msgSend1, msgSend2}) } // StdSignBytes returns the bytes to sign for a transaction. @@ -497,80 +489,35 @@ func StdSignBytes(cdc *codec.LegacyAmino, chainID string, accnum uint64, sequenc } func (suite *AnteTestSuite) CreateTestEIP712SingleMessageTxBuilder( - from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins, msg sdk.Msg, + priv cryptotypes.PrivKey, chainID string, gas uint64, gasAmount sdk.Coins, msg sdk.Msg, ) client.TxBuilder { - return suite.CreateTestEIP712CosmosTxBuilder(from, priv, chainId, gas, gasAmount, []sdk.Msg{msg}) + return suite.CreateTestEIP712CosmosTxBuilder(priv, chainID, gas, gasAmount, []sdk.Msg{msg}) } func (suite *AnteTestSuite) CreateTestEIP712CosmosTxBuilder( - from sdk.AccAddress, priv cryptotypes.PrivKey, chainId string, gas uint64, gasAmount sdk.Coins, msgs []sdk.Msg, + priv cryptotypes.PrivKey, chainID string, gas uint64, gasAmount sdk.Coins, msgs []sdk.Msg, ) client.TxBuilder { - var err error - - nonce, err := suite.app.AccountKeeper.GetSequence(suite.ctx, from) - suite.Require().NoError(err) - - pc, err := types.ParseChainID(chainId) - suite.Require().NoError(err) - ethChainId := pc.Uint64() - - // GenerateTypedData TypedData - var ethermintCodec codec.ProtoCodecMarshaler - registry := codectypes.NewInterfaceRegistry() - types.RegisterInterfaces(registry) - ethermintCodec = codec.NewProtoCodec(registry) - cryptocodec.RegisterInterfaces(registry) - - fee := legacytx.NewStdFee(gas, gasAmount) - accNumber := suite.app.AccountKeeper.GetAccount(suite.ctx, from).GetAccountNumber() - - data := legacytx.StdSignBytes(chainId, accNumber, nonce, 0, fee, msgs, "", nil) - typedData, err := eip712.WrapTxToTypedData(ethermintCodec, ethChainId, msgs[0], data, &eip712.FeeDelegationOptions{ - FeePayer: from, - }) - suite.Require().NoError(err) - - sigHash, _, err := apitypes.TypedDataAndHash(typedData) - suite.Require().NoError(err) - - // Sign typedData - keyringSigner := tests.NewSigner(priv) - signature, pubKey, err := keyringSigner.SignByAddress(from, sigHash) - suite.Require().NoError(err) - signature[crypto.RecoveryIDOffset] += 27 // Transform V from 0/1 to 27/28 according to the yellow paper - - // Add ExtensionOptionsWeb3Tx extension - var option *codectypes.Any - option, err = codectypes.NewAnyWithValue(&types.ExtensionOptionsWeb3Tx{ - FeePayer: from.String(), - TypedDataChainID: ethChainId, - FeePayerSig: signature, - }) - suite.Require().NoError(err) - - suite.clientCtx.TxConfig.SignModeHandler() - txBuilder := suite.clientCtx.TxConfig.NewTxBuilder() - builder, ok := txBuilder.(authtx.ExtensionOptionsTxBuilder) - suite.Require().True(ok) - - builder.SetExtensionOptions(option) - builder.SetFeeAmount(gasAmount) - builder.SetGasLimit(gas) - - sigsV2 := signing.SignatureV2{ - PubKey: pubKey, - Data: &signing.SingleSignatureData{ - SignMode: signing.SignMode_SIGN_MODE_LEGACY_AMINO_JSON, - }, - Sequence: nonce, + config := suite.clientCtx.TxConfig + cosmosTxArgs := utiltx.CosmosTxArgs{ + TxCfg: config, + Priv: priv, + ChainID: chainID, + Gas: gas, + Fees: gasAmount, + Msgs: msgs, } - err = builder.SetSignatures(sigsV2) - suite.Require().NoError(err) + builder, err := utiltx.PrepareEIP712CosmosTx( + suite.ctx, + suite.app, + utiltx.EIP712TxArgs{ + CosmosTxArgs: cosmosTxArgs, + UseLegacyExtension: suite.useLegacyEIP712Extension, + UseLegacyTypedData: suite.useLegacyEIP712TypedData, + }, + ) - err = builder.SetMsgs(msgs...) suite.Require().NoError(err) - return builder } diff --git a/crypto/ethsecp256k1/ethsecp256k1.go b/crypto/ethsecp256k1/ethsecp256k1.go index d01a357d67..599af5b239 100644 --- a/crypto/ethsecp256k1/ethsecp256k1.go +++ b/crypto/ethsecp256k1/ethsecp256k1.go @@ -236,7 +236,17 @@ func (pubKey PubKey) verifySignatureAsEIP712(msg, sig []byte) bool { return false } - return pubKey.verifySignatureECDSA(eip712Bytes, sig) + if pubKey.verifySignatureECDSA(eip712Bytes, sig) { + return true + } + + // Try verifying the signature using the legacy EIP-712 encoding + legacyEIP712Bytes, err := eip712.LegacyGetEIP712BytesForMsg(msg) + if err != nil { + return false + } + + return pubKey.verifySignatureECDSA(legacyEIP712Bytes, sig) } // Perform standard ECDSA signature verification for the given raw bytes and signature. diff --git a/ethereum/eip712/domain.go b/ethereum/eip712/domain.go new file mode 100644 index 0000000000..5c63df4299 --- /dev/null +++ b/ethereum/eip712/domain.go @@ -0,0 +1,34 @@ +// Copyright 2023 Evmos Foundation +// This file is part of Evmos' Ethermint library. +// +// The Ethermint library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The Ethermint library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the Ethermint library. If not, see https://github.com/evmos/ethermint/blob/main/LICENSE +package eip712 + +import ( + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +// createEIP712Domain creates the typed data domain for the given chainID. +func createEIP712Domain(chainID uint64) apitypes.TypedDataDomain { + domain := apitypes.TypedDataDomain{ + Name: "Cosmos Web3", + Version: "1.0.0", + ChainId: math.NewHexOrDecimal256(int64(chainID)), // #nosec G701 + VerifyingContract: "cosmos", + Salt: "0", + } + + return domain +} diff --git a/ethereum/eip712/eip712.go b/ethereum/eip712/eip712.go index de51983b40..cef97b31b1 100644 --- a/ethereum/eip712/eip712.go +++ b/ethereum/eip712/eip712.go @@ -1,4 +1,4 @@ -// Copyright 2021 Evmos Foundation +// Copyright 2023 Evmos Foundation // This file is part of Evmos' Ethermint library. // // The Ethermint library is free software: you can redistribute it and/or modify @@ -16,475 +16,34 @@ package eip712 import ( - "bytes" - "encoding/json" - "fmt" - "math/big" - "reflect" - "strings" - "time" - - sdkmath "cosmossdk.io/math" - "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" - "golang.org/x/text/cases" - "golang.org/x/text/language" - - errorsmod "cosmossdk.io/errors" - codectypes "github.com/cosmos/cosmos-sdk/codec/types" - sdk "github.com/cosmos/cosmos-sdk/types" - errortypes "github.com/cosmos/cosmos-sdk/types/errors" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/math" "github.com/ethereum/go-ethereum/signer/core/apitypes" ) -// WrapTxToTypedData is an ultimate method that wraps Amino-encoded Cosmos Tx JSON data -// into an EIP712-compatible TypedData request. +// WrapTxToTypedData wraps an Amino-encoded Cosmos Tx JSON SignDoc +// bytestream into an EIP712-compatible TypedData request. func WrapTxToTypedData( - cdc codectypes.AnyUnpacker, chainID uint64, - msg sdk.Msg, data []byte, - feeDelegation *FeeDelegationOptions, ) (apitypes.TypedData, error) { - txData := make(map[string]interface{}) - - if err := json.Unmarshal(data, &txData); err != nil { - return apitypes.TypedData{}, errorsmod.Wrap(errortypes.ErrJSONUnmarshal, "failed to JSON unmarshal data") - } - - domain := apitypes.TypedDataDomain{ - Name: "Cosmos Web3", - Version: "1.0.0", - ChainId: math.NewHexOrDecimal256(int64(chainID)), - VerifyingContract: "cosmos", - Salt: "0", + messagePayload, err := createEIP712MessagePayload(data) + message := messagePayload.message + if err != nil { + return apitypes.TypedData{}, err } - msgTypes, err := extractMsgTypes(cdc, "MsgValue", msg) + types, err := createEIP712Types(messagePayload) if err != nil { return apitypes.TypedData{}, err } - if feeDelegation != nil { - feeInfo, ok := txData["fee"].(map[string]interface{}) - if !ok { - return apitypes.TypedData{}, errorsmod.Wrap(errortypes.ErrInvalidType, "cannot parse fee from tx data") - } - - feeInfo["feePayer"] = feeDelegation.FeePayer.String() - - // also patching msgTypes to include feePayer - msgTypes["Fee"] = []apitypes.Type{ - {Name: "feePayer", Type: "string"}, - {Name: "amount", Type: "Coin[]"}, - {Name: "gas", Type: "string"}, - } - } + domain := createEIP712Domain(chainID) typedData := apitypes.TypedData{ - Types: msgTypes, - PrimaryType: "Tx", + Types: types, + PrimaryType: txField, Domain: domain, - Message: txData, + Message: message, } return typedData, nil } - -type FeeDelegationOptions struct { - FeePayer sdk.AccAddress -} - -func extractMsgTypes(cdc codectypes.AnyUnpacker, msgTypeName string, msg sdk.Msg) (apitypes.Types, error) { - rootTypes := apitypes.Types{ - "EIP712Domain": { - { - Name: "name", - Type: "string", - }, - { - Name: "version", - Type: "string", - }, - { - Name: "chainId", - Type: "uint256", - }, - { - Name: "verifyingContract", - Type: "string", - }, - { - Name: "salt", - Type: "string", - }, - }, - "Tx": { - {Name: "account_number", Type: "string"}, - {Name: "chain_id", Type: "string"}, - {Name: "fee", Type: "Fee"}, - {Name: "memo", Type: "string"}, - {Name: "msgs", Type: "Msg[]"}, - {Name: "sequence", Type: "string"}, - // Note timeout_height was removed because it was not getting filled with the legacyTx - // {Name: "timeout_height", Type: "string"}, - }, - "Fee": { - {Name: "amount", Type: "Coin[]"}, - {Name: "gas", Type: "string"}, - }, - "Coin": { - {Name: "denom", Type: "string"}, - {Name: "amount", Type: "string"}, - }, - "Msg": { - {Name: "type", Type: "string"}, - {Name: "value", Type: msgTypeName}, - }, - msgTypeName: {}, - } - - if err := walkFields(cdc, rootTypes, msgTypeName, msg); err != nil { - return nil, err - } - - return rootTypes, nil -} - -const typeDefPrefix = "_" - -func walkFields(cdc codectypes.AnyUnpacker, typeMap apitypes.Types, rootType string, in interface{}) (err error) { - defer doRecover(&err) - - t := reflect.TypeOf(in) - v := reflect.ValueOf(in) - - for { - if t.Kind() == reflect.Ptr || - t.Kind() == reflect.Interface { - t = t.Elem() - v = v.Elem() - - continue - } - - break - } - - return traverseFields(cdc, typeMap, rootType, typeDefPrefix, t, v) -} - -type cosmosAnyWrapper struct { - Type string `json:"type"` - Value interface{} `json:"value"` -} - -func traverseFields( - cdc codectypes.AnyUnpacker, - typeMap apitypes.Types, - rootType string, - prefix string, - t reflect.Type, - v reflect.Value, -) error { - n := t.NumField() - - if prefix == typeDefPrefix { - if len(typeMap[rootType]) == n { - return nil - } - } else { - typeDef := sanitizeTypedef(prefix) - if len(typeMap[typeDef]) == n { - return nil - } - } - - for i := 0; i < n; i++ { - var ( - field reflect.Value - err error - ) - - if v.IsValid() { - field = v.Field(i) - } - - fieldType := t.Field(i).Type - fieldName := jsonNameFromTag(t.Field(i).Tag) - - if fieldType == cosmosAnyType { - // Unpack field, value as Any - if fieldType, field, err = unpackAny(cdc, field); err != nil { - return err - } - } - - // If field is an empty value, do not include in types, since it will not be present in the object - if field.IsZero() { - continue - } - - for { - if fieldType.Kind() == reflect.Ptr { - fieldType = fieldType.Elem() - - if field.IsValid() { - field = field.Elem() - } - - continue - } - - if fieldType.Kind() == reflect.Interface { - fieldType = reflect.TypeOf(field.Interface()) - continue - } - - if field.Kind() == reflect.Ptr { - field = field.Elem() - continue - } - - break - } - - var isCollection bool - if fieldType.Kind() == reflect.Array || fieldType.Kind() == reflect.Slice { - if field.Len() == 0 { - // skip empty collections from type mapping - continue - } - - fieldType = fieldType.Elem() - field = field.Index(0) - isCollection = true - - if fieldType == cosmosAnyType { - if fieldType, field, err = unpackAny(cdc, field); err != nil { - return err - } - } - } - - for { - if fieldType.Kind() == reflect.Ptr { - fieldType = fieldType.Elem() - - if field.IsValid() { - field = field.Elem() - } - - continue - } - - if fieldType.Kind() == reflect.Interface { - fieldType = reflect.TypeOf(field.Interface()) - continue - } - - if field.Kind() == reflect.Ptr { - field = field.Elem() - continue - } - - break - } - - fieldPrefix := fmt.Sprintf("%s.%s", prefix, fieldName) - - ethTyp := typToEth(fieldType) - if len(ethTyp) > 0 { - // Support array of uint64 - if isCollection && fieldType.Kind() != reflect.Slice && fieldType.Kind() != reflect.Array { - ethTyp += "[]" - } - - if prefix == typeDefPrefix { - typeMap[rootType] = append(typeMap[rootType], apitypes.Type{ - Name: fieldName, - Type: ethTyp, - }) - } else { - typeDef := sanitizeTypedef(prefix) - typeMap[typeDef] = append(typeMap[typeDef], apitypes.Type{ - Name: fieldName, - Type: ethTyp, - }) - } - - continue - } - - if fieldType.Kind() == reflect.Struct { - var fieldTypedef string - - if isCollection { - fieldTypedef = sanitizeTypedef(fieldPrefix) + "[]" - } else { - fieldTypedef = sanitizeTypedef(fieldPrefix) - } - - if prefix == typeDefPrefix { - typeMap[rootType] = append(typeMap[rootType], apitypes.Type{ - Name: fieldName, - Type: fieldTypedef, - }) - } else { - typeDef := sanitizeTypedef(prefix) - typeMap[typeDef] = append(typeMap[typeDef], apitypes.Type{ - Name: fieldName, - Type: fieldTypedef, - }) - } - - if err := traverseFields(cdc, typeMap, rootType, fieldPrefix, fieldType, field); err != nil { - return err - } - - continue - } - } - - return nil -} - -func jsonNameFromTag(tag reflect.StructTag) string { - jsonTags := tag.Get("json") - parts := strings.Split(jsonTags, ",") - return parts[0] -} - -// Unpack the given Any value with Type/Value deconstruction -func unpackAny(cdc codectypes.AnyUnpacker, field reflect.Value) (reflect.Type, reflect.Value, error) { - anyData, ok := field.Interface().(*codectypes.Any) - if !ok { - return nil, reflect.Value{}, errorsmod.Wrapf(errortypes.ErrPackAny, "%T", field.Interface()) - } - - anyWrapper := &cosmosAnyWrapper{ - Type: anyData.TypeUrl, - } - - if err := cdc.UnpackAny(anyData, &anyWrapper.Value); err != nil { - return nil, reflect.Value{}, errorsmod.Wrap(err, "failed to unpack Any in msg struct") - } - - fieldType := reflect.TypeOf(anyWrapper) - field = reflect.ValueOf(anyWrapper) - - return fieldType, field, nil -} - -// _.foo_bar.baz -> TypeFooBarBaz -// -// this is needed for Geth's own signing code which doesn't -// tolerate complex type names -func sanitizeTypedef(str string) string { - buf := new(bytes.Buffer) - parts := strings.Split(str, ".") - caser := cases.Title(language.English, cases.NoLower) - - for _, part := range parts { - if part == "_" { - buf.WriteString("Type") - continue - } - - subparts := strings.Split(part, "_") - for _, subpart := range subparts { - buf.WriteString(caser.String(subpart)) - } - } - - return buf.String() -} - -var ( - hashType = reflect.TypeOf(common.Hash{}) - addressType = reflect.TypeOf(common.Address{}) - bigIntType = reflect.TypeOf(big.Int{}) - cosmIntType = reflect.TypeOf(sdkmath.Int{}) - cosmDecType = reflect.TypeOf(sdk.Dec{}) - cosmosAnyType = reflect.TypeOf(&codectypes.Any{}) - timeType = reflect.TypeOf(time.Time{}) - - edType = reflect.TypeOf(ed25519.PubKey{}) -) - -// typToEth supports only basic types and arrays of basic types. -// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md -func typToEth(typ reflect.Type) string { - const str = "string" - - switch typ.Kind() { - case reflect.String: - return str - case reflect.Bool: - return "bool" - case reflect.Int: - return "int64" - case reflect.Int8: - return "int8" - case reflect.Int16: - return "int16" - case reflect.Int32: - return "int32" - case reflect.Int64: - return "int64" - case reflect.Uint: - return "uint64" - case reflect.Uint8: - return "uint8" - case reflect.Uint16: - return "uint16" - case reflect.Uint32: - return "uint32" - case reflect.Uint64: - return "uint64" - case reflect.Slice: - ethName := typToEth(typ.Elem()) - if len(ethName) > 0 { - return ethName + "[]" - } - case reflect.Array: - ethName := typToEth(typ.Elem()) - if len(ethName) > 0 { - return ethName + "[]" - } - case reflect.Ptr: - if typ.Elem().ConvertibleTo(bigIntType) || - typ.Elem().ConvertibleTo(timeType) || - typ.Elem().ConvertibleTo(edType) || - typ.Elem().ConvertibleTo(cosmDecType) || - typ.Elem().ConvertibleTo(cosmIntType) { - return str - } - case reflect.Struct: - if typ.ConvertibleTo(hashType) || - typ.ConvertibleTo(addressType) || - typ.ConvertibleTo(bigIntType) || - typ.ConvertibleTo(edType) || - typ.ConvertibleTo(timeType) || - typ.ConvertibleTo(cosmDecType) || - typ.ConvertibleTo(cosmIntType) { - return str - } - } - - return "" -} - -func doRecover(err *error) { - if r := recover(); r != nil { - if e, ok := r.(error); ok { - e = errorsmod.Wrap(e, "panicked with error") - *err = e - return - } - - *err = fmt.Errorf("%v", r) - } -} diff --git a/ethereum/eip712/eip712_fuzzer_test.go b/ethereum/eip712/eip712_fuzzer_test.go new file mode 100644 index 0000000000..059f7513b8 --- /dev/null +++ b/ethereum/eip712/eip712_fuzzer_test.go @@ -0,0 +1,193 @@ +package eip712_test + +import ( + "fmt" + "strings" + + rand "github.com/tendermint/tendermint/libs/rand" + + "github.com/evmos/ethermint/ethereum/eip712" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type EIP712FuzzTestParams struct { + numTestObjects int + maxNumFieldsPerObject int + minStringLength int + maxStringLength int + randomFloatRange float64 + maxArrayLength int + maxObjectDepth int +} + +const ( + numPrimitiveJSONTypes = 3 + numJSONTypes = 5 + asciiRangeStart = 65 + asciiRangeEnd = 127 + fuzzTestName = "Flatten" +) + +const ( + jsonBoolType = iota + jsonStringType = iota + jsonFloatType = iota + jsonArrayType = iota + jsonObjectType = iota +) + +var params = EIP712FuzzTestParams{ + numTestObjects: 16, + maxNumFieldsPerObject: 16, + minStringLength: 16, + maxStringLength: 48, + randomFloatRange: 120000000, + maxArrayLength: 8, + maxObjectDepth: 4, +} + +// TestRandomPayloadFlattening generates many random payloads with different JSON values to ensure +// that Flattening works across all inputs. +// Note that this is a fuzz test, although it doesn't use Go's Fuzz testing suite, since there are +// variable input sizes, types, and fields. While it may be possible to translate a single input into +// a JSON object, it would require difficult parsing, and ultimately approximates our randomized unit +// tests as they are. +func (suite *EIP712TestSuite) TestRandomPayloadFlattening() { + // Re-seed rand generator + rand.Seed(rand.Int64()) + + for i := 0; i < params.numTestObjects; i++ { + suite.Run(fmt.Sprintf("%v%d", fuzzTestName, i), func() { + payload := suite.generateRandomPayload(i) + + flattened, numMessages, err := eip712.FlattenPayloadMessages(payload) + + suite.Require().NoError(err) + suite.Require().Equal(numMessages, i) + + suite.verifyPayloadAgainstFlattened(payload, flattened) + }) + } +} + +// generateRandomPayload creates a random payload of the desired format, with random sub-objects. +func (suite *EIP712TestSuite) generateRandomPayload(numMessages int) gjson.Result { + payload := suite.createRandomJSONObject().Raw + msgs := make([]gjson.Result, numMessages) + + for i := 0; i < numMessages; i++ { + msgs[i] = suite.createRandomJSONObject() + } + + payload, err := sjson.Set(payload, msgsFieldName, msgs) + suite.Require().NoError(err) + + return gjson.Parse(payload) +} + +// createRandomJSONObject creates a JSON object with random fields. +func (suite *EIP712TestSuite) createRandomJSONObject() gjson.Result { + var err error + payloadRaw := "" + + numFields := suite.createRandomIntInRange(0, params.maxNumFieldsPerObject) + for i := 0; i < numFields; i++ { + key := suite.createRandomString() + + randField := suite.createRandomJSONField(i, 0) + payloadRaw, err = sjson.Set(payloadRaw, key, randField) + suite.Require().NoError(err) + } + + return gjson.Parse(payloadRaw) +} + +// createRandomJSONField creates a random field with a random JSON type, with the possibility of +// nested fields up to depth objects. +func (suite *EIP712TestSuite) createRandomJSONField(t int, depth int) interface{} { + switch t % numJSONTypes { + case jsonBoolType: + return suite.createRandomBoolean() + case jsonStringType: + return suite.createRandomString() + case jsonFloatType: + return suite.createRandomFloat() + case jsonArrayType: + return suite.createRandomJSONNestedArray(depth) + case jsonObjectType: + return suite.createRandomJSONNestedObject(depth) + default: + return nil + } +} + +// createRandomJSONNestedArray creates an array of random nested JSON fields. +func (suite *EIP712TestSuite) createRandomJSONNestedArray(depth int) []interface{} { + arr := make([]interface{}, rand.Intn(params.maxArrayLength)) + for i := range arr { + arr[i] = suite.createRandomJSONNestedField(depth) + } + + return arr +} + +// createRandomJSONNestedObject creates a key-value set of objects with random nested JSON fields. +func (suite *EIP712TestSuite) createRandomJSONNestedObject(depth int) interface{} { + numFields := rand.Intn(params.maxNumFieldsPerObject) + obj := make(map[string]interface{}) + + for i := 0; i < numFields; i++ { + subField := suite.createRandomJSONNestedField(depth) + + obj[suite.createRandomString()] = subField + } + + return obj +} + +// createRandomJSONNestedField serves as a helper for createRandomJSONField and returns a random +// subfield to populate an array or object type. +func (suite *EIP712TestSuite) createRandomJSONNestedField(depth int) interface{} { + var newFieldType int + + if depth == params.maxObjectDepth { + newFieldType = rand.Intn(numPrimitiveJSONTypes) + } else { + newFieldType = rand.Intn(numJSONTypes) + } + + return suite.createRandomJSONField(newFieldType, depth+1) +} + +func (suite *EIP712TestSuite) createRandomBoolean() bool { + return rand.Intn(2) == 0 +} + +func (suite *EIP712TestSuite) createRandomFloat() float64 { + return (rand.Float64() - 0.5) * params.randomFloatRange +} + +func (suite *EIP712TestSuite) createRandomString() string { + bzLen := suite.createRandomIntInRange(params.minStringLength, params.maxStringLength) + bz := make([]byte, bzLen) + + for i := 0; i < bzLen; i++ { + bz[i] = byte(suite.createRandomIntInRange(asciiRangeStart, asciiRangeEnd)) + } + + str := string(bz) + + // Remove control characters, since they will make JSON invalid + str = strings.ReplaceAll(str, "{", "") + str = strings.ReplaceAll(str, "}", "") + str = strings.ReplaceAll(str, "]", "") + str = strings.ReplaceAll(str, "[", "") + + return str +} + +// createRandomIntInRange provides a random integer between [min, max) +func (suite *EIP712TestSuite) createRandomIntInRange(min int, max int) int { + return rand.Intn(max-min) + min +} diff --git a/ethereum/eip712/eip712_legacy.go b/ethereum/eip712/eip712_legacy.go new file mode 100644 index 0000000000..d0f432c192 --- /dev/null +++ b/ethereum/eip712/eip712_legacy.go @@ -0,0 +1,452 @@ +// Copyright 2023 Evmos Foundation +// This file is part of Evmos' Ethermint library. +// +// The Ethermint library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The Ethermint library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the Ethermint library. If not, see https://github.com/evmos/ethermint/blob/main/LICENSE +package eip712 + +import ( + "encoding/json" + "fmt" + "math/big" + "reflect" // #nosec G702 for sensitive import + "strings" + "time" + + errorsmod "cosmossdk.io/errors" + sdkmath "cosmossdk.io/math" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" + sdk "github.com/cosmos/cosmos-sdk/types" + errortypes "github.com/cosmos/cosmos-sdk/types/errors" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +type FeeDelegationOptions struct { + FeePayer sdk.AccAddress +} + +const ( + typeDefPrefix = "_" +) + +// LegacyWrapTxToTypedData is an ultimate method that wraps Amino-encoded Cosmos Tx JSON data +// into an EIP712-compatible TypedData request. +func LegacyWrapTxToTypedData( + cdc codectypes.AnyUnpacker, + chainID uint64, + msg sdk.Msg, + data []byte, + feeDelegation *FeeDelegationOptions, +) (apitypes.TypedData, error) { + txData := make(map[string]interface{}) + + if err := json.Unmarshal(data, &txData); err != nil { + return apitypes.TypedData{}, errorsmod.Wrap(errortypes.ErrJSONUnmarshal, "failed to JSON unmarshal data") + } + + domain := apitypes.TypedDataDomain{ + Name: "Cosmos Web3", + Version: "1.0.0", + ChainId: math.NewHexOrDecimal256(int64(chainID)), + VerifyingContract: "cosmos", + Salt: "0", + } + + msgTypes, err := extractMsgTypes(cdc, "MsgValue", msg) + if err != nil { + return apitypes.TypedData{}, err + } + + if feeDelegation != nil { + feeInfo, ok := txData["fee"].(map[string]interface{}) + if !ok { + return apitypes.TypedData{}, errorsmod.Wrap(errortypes.ErrInvalidType, "cannot parse fee from tx data") + } + + feeInfo["feePayer"] = feeDelegation.FeePayer.String() + + // also patching msgTypes to include feePayer + msgTypes["Fee"] = []apitypes.Type{ + {Name: "feePayer", Type: "string"}, + {Name: "amount", Type: "Coin[]"}, + {Name: "gas", Type: "string"}, + } + } + + typedData := apitypes.TypedData{ + Types: msgTypes, + PrimaryType: "Tx", + Domain: domain, + Message: txData, + } + + return typedData, nil +} + +func extractMsgTypes(cdc codectypes.AnyUnpacker, msgTypeName string, msg sdk.Msg) (apitypes.Types, error) { + rootTypes := apitypes.Types{ + "EIP712Domain": { + { + Name: "name", + Type: "string", + }, + { + Name: "version", + Type: "string", + }, + { + Name: "chainId", + Type: "uint256", + }, + { + Name: "verifyingContract", + Type: "string", + }, + { + Name: "salt", + Type: "string", + }, + }, + "Tx": { + {Name: "account_number", Type: "string"}, + {Name: "chain_id", Type: "string"}, + {Name: "fee", Type: "Fee"}, + {Name: "memo", Type: "string"}, + {Name: "msgs", Type: "Msg[]"}, + {Name: "sequence", Type: "string"}, + // Note timeout_height was removed because it was not getting filled with the legacyTx + // {Name: "timeout_height", Type: "string"}, + }, + "Fee": { + {Name: "amount", Type: "Coin[]"}, + {Name: "gas", Type: "string"}, + }, + "Coin": { + {Name: "denom", Type: "string"}, + {Name: "amount", Type: "string"}, + }, + "Msg": { + {Name: "type", Type: "string"}, + {Name: "value", Type: msgTypeName}, + }, + msgTypeName: {}, + } + + if err := walkFields(cdc, rootTypes, msgTypeName, msg); err != nil { + return nil, err + } + + return rootTypes, nil +} + +func walkFields(cdc codectypes.AnyUnpacker, typeMap apitypes.Types, rootType string, in interface{}) (err error) { + defer doRecover(&err) + + t := reflect.TypeOf(in) + v := reflect.ValueOf(in) + + for { + if t.Kind() == reflect.Ptr || + t.Kind() == reflect.Interface { + t = t.Elem() + v = v.Elem() + + continue + } + + break + } + + return legacyTraverseFields(cdc, typeMap, rootType, typeDefPrefix, t, v) +} + +type cosmosAnyWrapper struct { + Type string `json:"type"` + Value interface{} `json:"value"` +} + +func legacyTraverseFields( + cdc codectypes.AnyUnpacker, + typeMap apitypes.Types, + rootType string, + prefix string, + t reflect.Type, + v reflect.Value, +) error { + n := t.NumField() + + if prefix == typeDefPrefix { + if len(typeMap[rootType]) == n { + return nil + } + } else { + typeDef := sanitizeTypedef(prefix) + if len(typeMap[typeDef]) == n { + return nil + } + } + + for i := 0; i < n; i++ { + var ( + field reflect.Value + err error + ) + + if v.IsValid() { + field = v.Field(i) + } + + fieldType := t.Field(i).Type + fieldName := jsonNameFromTag(t.Field(i).Tag) + + if fieldType == cosmosAnyType { + // Unpack field, value as Any + if fieldType, field, err = unpackAny(cdc, field); err != nil { + return err + } + } + + // If field is an empty value, do not include in types, since it will not be present in the object + if field.IsZero() { + continue + } + + for { + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + + if field.IsValid() { + field = field.Elem() + } + + continue + } + + if fieldType.Kind() == reflect.Interface { + fieldType = reflect.TypeOf(field.Interface()) + continue + } + + if field.Kind() == reflect.Ptr { + field = field.Elem() + continue + } + + break + } + + var isCollection bool + if fieldType.Kind() == reflect.Array || fieldType.Kind() == reflect.Slice { + if field.Len() == 0 { + // skip empty collections from type mapping + continue + } + + fieldType = fieldType.Elem() + field = field.Index(0) + isCollection = true + + if fieldType == cosmosAnyType { + if fieldType, field, err = unpackAny(cdc, field); err != nil { + return err + } + } + } + + for { + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + + if field.IsValid() { + field = field.Elem() + } + + continue + } + + if fieldType.Kind() == reflect.Interface { + fieldType = reflect.TypeOf(field.Interface()) + continue + } + + if field.Kind() == reflect.Ptr { + field = field.Elem() + continue + } + + break + } + + fieldPrefix := fmt.Sprintf("%s.%s", prefix, fieldName) + + ethTyp := typToEth(fieldType) + + if len(ethTyp) > 0 { + // Support array of uint64 + if isCollection && fieldType.Kind() != reflect.Slice && fieldType.Kind() != reflect.Array { + ethTyp += "[]" + } + + if prefix == typeDefPrefix { + typeMap[rootType] = append(typeMap[rootType], apitypes.Type{ + Name: fieldName, + Type: ethTyp, + }) + } else { + typeDef := sanitizeTypedef(prefix) + typeMap[typeDef] = append(typeMap[typeDef], apitypes.Type{ + Name: fieldName, + Type: ethTyp, + }) + } + + continue + } + + if fieldType.Kind() == reflect.Struct { + var fieldTypedef string + + if isCollection { + fieldTypedef = sanitizeTypedef(fieldPrefix) + "[]" + } else { + fieldTypedef = sanitizeTypedef(fieldPrefix) + } + + if prefix == typeDefPrefix { + typeMap[rootType] = append(typeMap[rootType], apitypes.Type{ + Name: fieldName, + Type: fieldTypedef, + }) + } else { + typeDef := sanitizeTypedef(prefix) + typeMap[typeDef] = append(typeMap[typeDef], apitypes.Type{ + Name: fieldName, + Type: fieldTypedef, + }) + } + + if err := legacyTraverseFields(cdc, typeMap, rootType, fieldPrefix, fieldType, field); err != nil { + return err + } + + continue + } + } + + return nil +} + +func jsonNameFromTag(tag reflect.StructTag) string { + jsonTags := tag.Get("json") + parts := strings.Split(jsonTags, ",") + return parts[0] +} + +// Unpack the given Any value with Type/Value deconstruction +func unpackAny(cdc codectypes.AnyUnpacker, field reflect.Value) (reflect.Type, reflect.Value, error) { + anyData, ok := field.Interface().(*codectypes.Any) + if !ok { + return nil, reflect.Value{}, errorsmod.Wrapf(errortypes.ErrPackAny, "%T", field.Interface()) + } + + anyWrapper := &cosmosAnyWrapper{ + Type: anyData.TypeUrl, + } + + if err := cdc.UnpackAny(anyData, &anyWrapper.Value); err != nil { + return nil, reflect.Value{}, errorsmod.Wrap(err, "failed to unpack Any in msg struct") + } + + fieldType := reflect.TypeOf(anyWrapper) + field = reflect.ValueOf(anyWrapper) + + return fieldType, field, nil +} + +var ( + hashType = reflect.TypeOf(common.Hash{}) + addressType = reflect.TypeOf(common.Address{}) + bigIntType = reflect.TypeOf(big.Int{}) + cosmIntType = reflect.TypeOf(sdkmath.Int{}) + cosmDecType = reflect.TypeOf(sdk.Dec{}) + timeType = reflect.TypeOf(time.Time{}) + cosmosAnyType = reflect.TypeOf(&codectypes.Any{}) + edType = reflect.TypeOf(ed25519.PubKey{}) +) + +// typToEth supports only basic types and arrays of basic types. +// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md +func typToEth(typ reflect.Type) string { + const str = "string" + + switch typ.Kind() { + case reflect.String: + return str + case reflect.Bool: + return "bool" + case reflect.Int: + return "int64" + case reflect.Int8: + return "int8" + case reflect.Int16: + return "int16" + case reflect.Int32: + return "int32" + case reflect.Int64: + return "int64" + case reflect.Uint: + return "uint64" + case reflect.Uint8: + return "uint8" + case reflect.Uint16: + return "uint16" + case reflect.Uint32: + return "uint32" + case reflect.Uint64: + return "uint64" + case reflect.Slice: + ethName := typToEth(typ.Elem()) + if len(ethName) > 0 { + return ethName + "[]" + } + case reflect.Array: + ethName := typToEth(typ.Elem()) + if len(ethName) > 0 { + return ethName + "[]" + } + case reflect.Ptr: + if typ.Elem().ConvertibleTo(bigIntType) || + typ.Elem().ConvertibleTo(timeType) || + typ.Elem().ConvertibleTo(edType) || + typ.Elem().ConvertibleTo(cosmDecType) || + typ.Elem().ConvertibleTo(cosmIntType) { + return str + } + case reflect.Struct: + if typ.ConvertibleTo(hashType) || + typ.ConvertibleTo(addressType) || + typ.ConvertibleTo(bigIntType) || + typ.ConvertibleTo(edType) || + typ.ConvertibleTo(timeType) || + typ.ConvertibleTo(cosmDecType) || + typ.ConvertibleTo(cosmIntType) { + return str + } + } + + return "" +} diff --git a/ethereum/eip712/eip712_test.go b/ethereum/eip712/eip712_test.go index 8d78c4d910..a0b3a50eaf 100644 --- a/ethereum/eip712/eip712_test.go +++ b/ethereum/eip712/eip712_test.go @@ -1,57 +1,84 @@ package eip712_test import ( + "bytes" + "fmt" "testing" "cosmossdk.io/math" "github.com/cosmos/cosmos-sdk/client" - "github.com/cosmos/cosmos-sdk/simapp/params" + chainparams "github.com/cosmos/cosmos-sdk/simapp/params" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" "github.com/evmos/ethermint/ethereum/eip712" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/evmos/ethermint/crypto/ethsecp256k1" - banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" - "github.com/evmos/ethermint/app" - "github.com/evmos/ethermint/encoding" - txtypes "github.com/cosmos/cosmos-sdk/types/tx" "github.com/cosmos/cosmos-sdk/types/tx/signing" authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/evmos/ethermint/app" + "github.com/evmos/ethermint/cmd/config" + "github.com/evmos/ethermint/encoding" + "github.com/evmos/ethermint/testutil" + evmtypes "github.com/evmos/ethermint/x/evm/types" distributiontypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + govtypesv1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1" govtypes "github.com/cosmos/cosmos-sdk/x/gov/types/v1beta1" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" "github.com/stretchr/testify/suite" ) -// Unit tests for single-signer EIP-712 signature verification. Multi-signer verification tests are included -// in ante_test.go. +// Unit tests for single-signer EIP-712 signature verification. Multi-signature key verification tests are out-of-scope +// here and included with the ante_tests. + +const ( + msgsFieldName = "msgs" +) type EIP712TestSuite struct { suite.Suite - config params.EncodingConfig - clientCtx client.Context + config chainparams.EncodingConfig + clientCtx client.Context + useLegacyEIP712TypedData bool + denom string +} + +type EIP712TestParams struct { + fee txtypes.Fee + address sdk.AccAddress + accountNumber uint64 + sequence uint64 + memo string } func TestEIP712TestSuite(t *testing.T) { suite.Run(t, &EIP712TestSuite{}) + // Note that we don't test the Legacy EIP-712 Extension, since that case + // is sufficiently covered by the AnteHandler tests. + suite.Run(t, &EIP712TestSuite{ + useLegacyEIP712TypedData: true, + }) } -// Set up test env to replicate prod. environment func (suite *EIP712TestSuite) SetupTest() { suite.config = encoding.MakeConfig(app.ModuleBasics) suite.clientCtx = client.Context{}.WithTxConfig(suite.config.TxConfig) + suite.denom = evmtypes.DefaultEVMDenom - sdk.GetConfig().SetBech32PrefixForAccount("ethm", "") + sdk.GetConfig().SetBech32PrefixForAccount(config.Bech32Prefix, "") eip712.SetEncodingConfig(suite.config) } -// Helper to create random test addresses for messages +// createTestAddress creates random test addresses for messages func (suite *EIP712TestSuite) createTestAddress() sdk.AccAddress { privkey, _ := ethsecp256k1.GenerateKey() key, err := privkey.ToECDSA() @@ -62,7 +89,7 @@ func (suite *EIP712TestSuite) createTestAddress() sdk.AccAddress { return addr.Bytes() } -// Helper to create random keypair for signing + verification +// createTestKeyPair creates a random keypair for signing and verification func (suite *EIP712TestSuite) createTestKeyPair() (*ethsecp256k1.PrivKey, *ethsecp256k1.PubKey) { privKey, err := ethsecp256k1.GenerateKey() suite.Require().NoError(err) @@ -75,7 +102,7 @@ func (suite *EIP712TestSuite) createTestKeyPair() (*ethsecp256k1.PrivKey, *ethse return privKey, pubKey } -// Helper to create instance of sdk.Coins[] with single coin +// makeCoins helps create an instance of sdk.Coins[] with single coin func (suite *EIP712TestSuite) makeCoins(denom string, amount math.Int) sdk.Coins { return sdk.NewCoins( sdk.NewCoin( @@ -85,7 +112,7 @@ func (suite *EIP712TestSuite) makeCoins(denom string, amount math.Int) sdk.Coins ) } -func (suite *EIP712TestSuite) TestEIP712SignatureVerification() { +func (suite *EIP712TestSuite) TestEIP712() { suite.SetupTest() signModes := []signing.SignMode{ @@ -93,45 +120,37 @@ func (suite *EIP712TestSuite) TestEIP712SignatureVerification() { signing.SignMode_SIGN_MODE_LEGACY_AMINO_JSON, } - // Fixed test address - testAddress := suite.createTestAddress() + params := EIP712TestParams{ + fee: txtypes.Fee{ + Amount: suite.makeCoins(suite.denom, math.NewInt(2000)), + GasLimit: 20000, + }, + address: suite.createTestAddress(), + accountNumber: 25, + sequence: 78, + memo: "", + } testCases := []struct { title string - chainId string - fee txtypes.Fee - memo string + chainID string msgs []sdk.Msg - accountNumber uint64 - sequence uint64 timeoutHeight uint64 expectSuccess bool }{ { title: "Succeeds - Standard MsgSend", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, - }, - memo: "", msgs: []sdk.Msg{ banktypes.NewMsgSend( suite.createTestAddress(), suite.createTestAddress(), - suite.makeCoins("photon", math.NewInt(1)), + suite.makeCoins(suite.denom, math.NewInt(1)), ), }, - accountNumber: 8, - sequence: 5, expectSuccess: true, }, { title: "Succeeds - Standard MsgVote", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, - }, - memo: "", msgs: []sdk.Msg{ govtypes.NewMsgVote( suite.createTestAddress(), @@ -139,134 +158,115 @@ func (suite *EIP712TestSuite) TestEIP712SignatureVerification() { govtypes.OptionNo, ), }, - accountNumber: 25, - sequence: 78, expectSuccess: true, }, { title: "Succeeds - Standard MsgDelegate", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, - }, - memo: "", msgs: []sdk.Msg{ stakingtypes.NewMsgDelegate( suite.createTestAddress(), sdk.ValAddress(suite.createTestAddress()), - suite.makeCoins("photon", math.NewInt(1))[0], + suite.makeCoins(suite.denom, math.NewInt(1))[0], ), }, - accountNumber: 25, - sequence: 78, expectSuccess: true, }, { title: "Succeeds - Standard MsgWithdrawDelegationReward", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, - }, - memo: "", msgs: []sdk.Msg{ distributiontypes.NewMsgWithdrawDelegatorReward( suite.createTestAddress(), sdk.ValAddress(suite.createTestAddress()), ), }, - accountNumber: 25, - sequence: 78, expectSuccess: true, }, { title: "Succeeds - Two Single-Signer MsgDelegate", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, - }, - memo: "", msgs: []sdk.Msg{ stakingtypes.NewMsgDelegate( - testAddress, + params.address, sdk.ValAddress(suite.createTestAddress()), - suite.makeCoins("photon", math.NewInt(1))[0], + suite.makeCoins(suite.denom, math.NewInt(1))[0], ), stakingtypes.NewMsgDelegate( - testAddress, + params.address, sdk.ValAddress(suite.createTestAddress()), - suite.makeCoins("photon", math.NewInt(5))[0], + suite.makeCoins(suite.denom, math.NewInt(5))[0], ), }, - accountNumber: 25, - sequence: 78, expectSuccess: true, }, { - title: "Fails - Two MsgVotes with Different Signers", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, + title: "Succeeds - Single-Signer MsgVote V1 with Omitted Value", + msgs: []sdk.Msg{ + govtypesv1.NewMsgVote( + params.address, + 5, + govtypesv1.VoteOption_VOTE_OPTION_NO, + "", + ), }, - memo: "", + expectSuccess: true, + }, + { + title: "Succeeds - Single-Signer MsgSend + MsgVote", msgs: []sdk.Msg{ govtypes.NewMsgVote( - suite.createTestAddress(), + params.address, 5, govtypes.OptionNo, ), - govtypes.NewMsgVote( + banktypes.NewMsgSend( + params.address, suite.createTestAddress(), - 25, - govtypes.OptionAbstain, + suite.makeCoins(suite.denom, math.NewInt(50)), ), }, - accountNumber: 25, - sequence: 78, - expectSuccess: false, + expectSuccess: !suite.useLegacyEIP712TypedData, }, { - title: "Fails - Empty transaction", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, + title: "Succeeds - Single-Signer 2x MsgVoteV1 with Different Schemas", + msgs: []sdk.Msg{ + govtypesv1.NewMsgVote( + params.address, + 5, + govtypesv1.VoteOption_VOTE_OPTION_NO, + "", + ), + govtypesv1.NewMsgVote( + params.address, + 10, + govtypesv1.VoteOption_VOTE_OPTION_YES, + "Has Metadata", + ), }, - memo: "", - msgs: []sdk.Msg{}, - accountNumber: 25, - sequence: 78, - expectSuccess: false, + expectSuccess: !suite.useLegacyEIP712TypedData, }, { - title: "Fails - Single-Signer MsgSend + MsgVote", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, - }, - memo: "", + title: "Fails - Two MsgVotes with Different Signers", msgs: []sdk.Msg{ govtypes.NewMsgVote( - testAddress, + suite.createTestAddress(), 5, govtypes.OptionNo, ), - banktypes.NewMsgSend( - testAddress, + govtypes.NewMsgVote( suite.createTestAddress(), - suite.makeCoins("photon", math.NewInt(50)), + 25, + govtypes.OptionAbstain, ), }, - accountNumber: 25, - sequence: 78, + expectSuccess: false, + }, + { + title: "Fails - Empty Transaction", + msgs: []sdk.Msg{}, expectSuccess: false, }, { title: "Fails - Invalid ChainID", - chainId: "invalidchainid", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, - }, - memo: "", + chainID: "invalidchainid", msgs: []sdk.Msg{ govtypes.NewMsgVote( suite.createTestAddress(), @@ -274,17 +274,10 @@ func (suite *EIP712TestSuite) TestEIP712SignatureVerification() { govtypes.OptionNo, ), }, - accountNumber: 25, - sequence: 78, expectSuccess: false, }, { title: "Fails - Includes TimeoutHeight", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, - }, - memo: "", msgs: []sdk.Msg{ govtypes.NewMsgVote( suite.createTestAddress(), @@ -292,44 +285,35 @@ func (suite *EIP712TestSuite) TestEIP712SignatureVerification() { govtypes.OptionNo, ), }, - accountNumber: 25, - sequence: 78, timeoutHeight: 1000, expectSuccess: false, }, { title: "Fails - Single Message / Multi-Signer", - fee: txtypes.Fee{ - Amount: suite.makeCoins("aphoton", math.NewInt(2000)), - GasLimit: 20000, - }, - memo: "", msgs: []sdk.Msg{ banktypes.NewMsgMultiSend( []banktypes.Input{ banktypes.NewInput( suite.createTestAddress(), - suite.makeCoins("photon", math.NewInt(50)), + suite.makeCoins(suite.denom, math.NewInt(50)), ), banktypes.NewInput( suite.createTestAddress(), - suite.makeCoins("photon", math.NewInt(50)), + suite.makeCoins(suite.denom, math.NewInt(50)), ), }, []banktypes.Output{ banktypes.NewOutput( suite.createTestAddress(), - suite.makeCoins("photon", math.NewInt(50)), + suite.makeCoins(suite.denom, math.NewInt(50)), ), banktypes.NewOutput( suite.createTestAddress(), - suite.makeCoins("photon", math.NewInt(50)), + suite.makeCoins(suite.denom, math.NewInt(50)), ), }, ), }, - accountNumber: 25, - sequence: 78, expectSuccess: false, }, } @@ -339,21 +323,17 @@ func (suite *EIP712TestSuite) TestEIP712SignatureVerification() { suite.Run(tc.title, func() { privKey, pubKey := suite.createTestKeyPair() - // Init tx builder txBuilder := suite.clientCtx.TxConfig.NewTxBuilder() - // Set gas and fees - txBuilder.SetGasLimit(tc.fee.GasLimit) - txBuilder.SetFeeAmount(tc.fee.Amount) + txBuilder.SetGasLimit(params.fee.GasLimit) + txBuilder.SetFeeAmount(params.fee.Amount) - // Set messages err := txBuilder.SetMsgs(tc.msgs...) suite.Require().NoError(err) - // Set memo - txBuilder.SetMemo(tc.memo) + txBuilder.SetMemo(params.memo) - // Prepare signature field + // Prepare signature field with empty signatures txSigData := signing.SingleSignatureData{ SignMode: signMode, Signature: nil, @@ -361,28 +341,27 @@ func (suite *EIP712TestSuite) TestEIP712SignatureVerification() { txSig := signing.SignatureV2{ PubKey: pubKey, Data: &txSigData, - Sequence: tc.sequence, + Sequence: params.sequence, } err = txBuilder.SetSignatures([]signing.SignatureV2{txSig}...) suite.Require().NoError(err) - chainId := "ethermint_9000-1" - if tc.chainId != "" { - chainId = tc.chainId + chainID := testutil.TestnetChainID + "-1" + if tc.chainID != "" { + chainID = tc.chainID } if tc.timeoutHeight != 0 { txBuilder.SetTimeoutHeight(tc.timeoutHeight) } - // Declare signerData signerData := authsigning.SignerData{ - ChainID: chainId, - AccountNumber: tc.accountNumber, - Sequence: tc.sequence, + ChainID: chainID, + AccountNumber: params.accountNumber, + Sequence: params.sequence, PubKey: pubKey, - Address: sdk.MustBech32ifyAddressBytes("ethm", pubKey.Bytes()), + Address: sdk.MustBech32ifyAddressBytes(config.Bech32Prefix, pubKey.Bytes()), } bz, err := suite.clientCtx.TxConfig.SignModeHandler().GetSignBytes( @@ -393,17 +372,29 @@ func (suite *EIP712TestSuite) TestEIP712SignatureVerification() { suite.Require().NoError(err) suite.verifyEIP712SignatureVerification(tc.expectSuccess, *privKey, *pubKey, bz) + + // Verify payload flattening only if the payload is in valid JSON format + if signMode == signing.SignMode_SIGN_MODE_LEGACY_AMINO_JSON { + suite.verifySignDocFlattening(bz) + + if tc.expectSuccess { + suite.verifyBasicTypedData(bz) + } + } }) } } } -// Verify that the payload passes signature verification if signed as its EIP-712 representation. +// verifyEIP712SignatureVerification verifies that the payload passes signature verification if signed as its EIP-712 representation. func (suite *EIP712TestSuite) verifyEIP712SignatureVerification(expectedSuccess bool, privKey ethsecp256k1.PrivKey, pubKey ethsecp256k1.PubKey, signBytes []byte) { - // Convert to EIP712 bytes and sign eip712Bytes, err := eip712.GetEIP712BytesForMsg(signBytes) + + if suite.useLegacyEIP712TypedData { + eip712Bytes, err = eip712.LegacyGetEIP712BytesForMsg(signBytes) + } + if !expectedSuccess { - // Expect failure generating EIP-712 bytes suite.Require().Error(err) return } @@ -426,7 +417,216 @@ func (suite *EIP712TestSuite) verifyEIP712SignatureVerification(expectedSuccess randBytes := make([]byte, len(signBytes)) copy(randBytes, signBytes) // Change the first element of signBytes to a different value - randBytes[0] = (signBytes[0] + 10) % 128 + randBytes[0] = (signBytes[0] + 10) % 255 res = pubKey.VerifySignature(randBytes, sig) suite.Require().False(res) } + +// verifySignDocFlattening tests the flattening algorithm against the sign doc's JSON payload, +// using verifyPayloadAgainstFlattened. +func (suite *EIP712TestSuite) verifySignDocFlattening(signDoc []byte) { + payload := gjson.ParseBytes(signDoc) + suite.Require().True(payload.IsObject()) + + flattened, _, err := eip712.FlattenPayloadMessages(payload) + suite.Require().NoError(err) + + suite.verifyPayloadAgainstFlattened(payload, flattened) +} + +// verifyPayloadAgainstFlattened compares a payload against its flattened counterpart to ensure that +// the flattening algorithm behaved as expected. +func (suite *EIP712TestSuite) verifyPayloadAgainstFlattened(payload gjson.Result, flattened gjson.Result) { + payloadMap, ok := payload.Value().(map[string]interface{}) + suite.Require().True(ok) + flattenedMap, ok := flattened.Value().(map[string]interface{}) + suite.Require().True(ok) + + suite.verifyPayloadMapAgainstFlattenedMap(payloadMap, flattenedMap) +} + +// verifyPayloadMapAgainstFlattenedMap directly compares two JSON maps in Go representations to +// test flattening. +func (suite *EIP712TestSuite) verifyPayloadMapAgainstFlattenedMap(original map[string]interface{}, flattened map[string]interface{}) { + interfaceMessages, ok := original[msgsFieldName] + suite.Require().True(ok) + + messages, ok := interfaceMessages.([]interface{}) + suite.Require().True(ok) + + // Verify message contents + for i, msg := range messages { + flattenedMsg, ok := flattened[fmt.Sprintf("msg%d", i)] + suite.Require().True(ok) + + flattenedMsgJSON, ok := flattenedMsg.(map[string]interface{}) + suite.Require().True(ok) + + suite.Require().Equal(flattenedMsgJSON, msg) + } + + // Verify new payload does not have msgs field + _, ok = flattened[msgsFieldName] + suite.Require().False(ok) + + // Verify number of total keys + numKeysOriginal := len(original) + numKeysFlattened := len(flattened) + numMessages := len(messages) + + // + N keys, then -1 for msgs + suite.Require().Equal(numKeysFlattened, numKeysOriginal+numMessages-1) + + // Verify contents of remaining keys + for k, obj := range original { + if k == msgsFieldName { + continue + } + + flattenedObj, ok := flattened[k] + suite.Require().True(ok) + + suite.Require().Equal(obj, flattenedObj) + } +} + +// verifyBasicTypedData performs basic verification on the TypedData generation. +func (suite *EIP712TestSuite) verifyBasicTypedData(signDoc []byte) { + typedData, err := eip712.GetEIP712TypedDataForMsg(signDoc) + + suite.Require().NoError(err) + + jsonPayload := gjson.ParseBytes(signDoc) + suite.Require().True(jsonPayload.IsObject()) + + flattened, _, err := eip712.FlattenPayloadMessages(jsonPayload) + suite.Require().NoError(err) + suite.Require().True(flattened.IsObject()) + + flattenedMsgMap, ok := flattened.Value().(map[string]interface{}) + suite.Require().True(ok) + + suite.Require().Equal(typedData.Message, flattenedMsgMap) +} + +// TestFlattenPayloadErrorHandling tests error handling in TypedData generation, +// specifically regarding the payload. +func (suite *EIP712TestSuite) TestFlattenPayloadErrorHandling() { + // No msgs + _, _, err := eip712.FlattenPayloadMessages(gjson.Parse("")) + suite.Require().ErrorContains(err, "no messages found") + + // Non-array Msgs + _, _, err = eip712.FlattenPayloadMessages(gjson.Parse(`{"msgs": 10}`)) + suite.Require().ErrorContains(err, "array of messages") + + // Array with non-object items + _, _, err = eip712.FlattenPayloadMessages(gjson.Parse(`{"msgs": [10, 20]}`)) + suite.Require().ErrorContains(err, "not valid JSON") + + // Malformed payload + malformed, err := sjson.Set(suite.generateRandomPayload(2).Raw, "msg0", 20) + suite.Require().NoError(err) + _, _, err = eip712.FlattenPayloadMessages(gjson.Parse(malformed)) + suite.Require().ErrorContains(err, "malformed payload") +} + +// TestTypedDataErrorHandling tests error handling for TypedData generation +// in the main algorithm. +func (suite *EIP712TestSuite) TestTypedDataErrorHandling() { + // Empty JSON + _, err := eip712.WrapTxToTypedData(0, make([]byte, 0)) + suite.Require().ErrorContains(err, "invalid JSON") + + _, err = eip712.WrapTxToTypedData(0, []byte(gjson.Parse(`{"msgs": 10}`).Raw)) + suite.Require().ErrorContains(err, "array of messages") + + // Invalid message 'type' + _, err = eip712.WrapTxToTypedData(0, []byte(gjson.Parse(`{"msgs": [{ "type": 10 }] }`).Raw)) + suite.Require().ErrorContains(err, "message type value") + + // Max duplicate type recursion depth + messagesArr := new(bytes.Buffer) + maxRecursionDepth := 1001 + + messagesArr.WriteString("[") + for i := 0; i < maxRecursionDepth; i++ { + messagesArr.WriteString(fmt.Sprintf(`{ "type": "msgType", "value": { "field%v": 10 } }`, i)) + if i != maxRecursionDepth-1 { + messagesArr.WriteString(",") + } + } + messagesArr.WriteString("]") + + _, err = eip712.WrapTxToTypedData(0, []byte(fmt.Sprintf(`{ "msgs": %v }`, messagesArr))) + suite.Require().ErrorContains(err, "maximum number of duplicates") +} + +// TestTypedDataEdgeCases tests certain interesting edge cases to ensure that they work +// (or don't work) as expected. +func (suite *EIP712TestSuite) TestTypedDataEdgeCases() { + // Type without '/' separator + typedData, err := eip712.WrapTxToTypedData(0, []byte(gjson.Parse(`{"msgs": [{ "type": "MsgSend", "value": { "field": 10 } }] }`).Raw)) + suite.Require().NoError(err) + types := typedData.Types["TypeMsgSend0"] + suite.Require().Greater(len(types), 0) + + // Null value + typedData, err = eip712.WrapTxToTypedData(0, []byte(gjson.Parse(`{"msgs": [{ "type": "MsgSend", "value": { "field": null } }] }`).Raw)) + suite.Require().NoError(err) + types = typedData.Types["TypeValue0"] + // Skip null type, since we don't expect any in the payload + suite.Require().Equal(len(types), 0) + + // Boolean value + typedData, err = eip712.WrapTxToTypedData(0, []byte(gjson.Parse(`{"msgs": [{ "type": "MsgSend", "value": { "field": true } }] }`).Raw)) + suite.Require().NoError(err) + types = typedData.Types["TypeValue0"] + suite.Require().Equal(len(types), 1) + suite.Require().Equal(types[0], apitypes.Type{ + Name: "field", + Type: "bool", + }) + + // Empty array + typedData, err = eip712.WrapTxToTypedData(0, []byte(gjson.Parse(`{"msgs": [{ "type": "MsgSend", "value": { "field": [] } }] }`).Raw)) + suite.Require().NoError(err) + types = typedData.Types["TypeValue0"] + suite.Require().Equal(types[0], apitypes.Type{ + Name: "field", + Type: "string[]", + }) + + // Simple arrays + typedData, err = eip712.WrapTxToTypedData(0, []byte(gjson.Parse(`{"msgs": [{ "type": "MsgSend", "value": { "array": [1, 2, 3] } }] }`).Raw)) + suite.Require().NoError(err) + types = typedData.Types["TypeValue0"] + suite.Require().Equal(len(types), 1) + suite.Require().Equal(types[0], apitypes.Type{ + Name: "array", + Type: "int64[]", + }) + + // Nested arrays (EIP-712 does not support nested arrays) + typedData, err = eip712.WrapTxToTypedData(0, []byte(gjson.Parse(`{"msgs": [{ "type": "MsgSend", "value": { "array": [[1, 2, 3], [1, 2]] } }] }`).Raw)) + suite.Require().NoError(err) + types = typedData.Types["TypeValue0"] + suite.Require().Equal(len(types), 0) +} + +// TestTypedDataGeneration tests certain qualities about the output Types representation. +func (suite *EIP712TestSuite) TestTypedDataGeneration() { + // Multiple messages with the same schema should share one type + payloadRaw := `{ "msgs": [{ "type": "msgType", "value": { "field1": 10 }}, { "type": "msgType", "value": { "field1": 20 }}] }` + + typedData, err := eip712.WrapTxToTypedData(0, []byte(payloadRaw)) + suite.Require().NoError(err) + suite.Require().True(typedData.Types["TypemsgType1"] == nil) + + // Multiple messages with different schemas should have different types + payloadRaw = `{ "msgs": [{ "type": "msgType", "value": { "field1": 10 }}, { "type": "msgType", "value": { "field2": 20 }}] }` + + typedData, err = eip712.WrapTxToTypedData(0, []byte(payloadRaw)) + suite.Require().NoError(err) + suite.Require().False(typedData.Types["TypemsgType1"] == nil) +} diff --git a/ethereum/eip712/encoding.go b/ethereum/eip712/encoding.go index 4e925d0b1e..d80c798871 100644 --- a/ethereum/eip712/encoding.go +++ b/ethereum/eip712/encoding.go @@ -1,4 +1,4 @@ -// Copyright 2021 Evmos Foundation +// Copyright 2023 Evmos Foundation // This file is part of Evmos' Ethermint library. // // The Ethermint library is free software: you can redistribute it and/or modify @@ -16,7 +16,6 @@ package eip712 import ( - "encoding/json" "errors" "fmt" @@ -27,16 +26,11 @@ import ( txTypes "github.com/cosmos/cosmos-sdk/types/tx" apitypes "github.com/ethereum/go-ethereum/signer/core/apitypes" - ethermint "github.com/evmos/ethermint/types" + "github.com/evmos/ethermint/types" "github.com/cosmos/cosmos-sdk/codec" ) -type aminoMessage struct { - Type string `json:"type"` - Value interface{} `json:"value"` -} - var ( protoCodec codec.ProtoCodecMarshaler aminoCodec *codec.LegacyAmino @@ -51,9 +45,8 @@ func SetEncodingConfig(cfg params.EncodingConfig) { protoCodec = codec.NewProtoCodec(cfg.InterfaceRegistry) } -// Get the EIP-712 object bytes for the given SignDoc bytes by first decoding the bytes into -// an EIP-712 object, then hashing the EIP-712 object to create the bytes to be signed. -// See https://eips.ethereum.org/EIPS/eip-712 for more. +// GetEIP712BytesForMsg returns the EIP-712 object bytes for the given SignDoc bytes by decoding the bytes into +// an EIP-712 object, then converting via WrapTxToTypedData. See https://eips.ethereum.org/EIPS/eip-712 for more. func GetEIP712BytesForMsg(signDocBytes []byte) ([]byte, error) { typedData, err := GetEIP712TypedDataForMsg(signDocBytes) if err != nil { @@ -123,26 +116,14 @@ func decodeAminoSignDoc(signDocBytes []byte) (apitypes.TypedData, error) { return apitypes.TypedData{}, err } - // Use first message for fee payer and type inference - msg := msgs[0] - - // By convention, the fee payer is the first address in the list of signers. - feePayer := msg.GetSigners()[0] - feeDelegation := &FeeDelegationOptions{ - FeePayer: feePayer, - } - - chainID, err := ethermint.ParseChainID(aminoDoc.ChainID) + chainID, err := types.ParseChainID(aminoDoc.ChainID) if err != nil { return apitypes.TypedData{}, errors.New("invalid chain ID passed as argument") } typedData, err := WrapTxToTypedData( - protoCodec, chainID.Uint64(), - msg, signDocBytes, - feeDelegation, ) if err != nil { return apitypes.TypedData{}, fmt.Errorf("could not convert to EIP712 representation: %w", err) @@ -197,12 +178,9 @@ func decodeProtobufSignDoc(signDocBytes []byte) (apitypes.TypedData, error) { return apitypes.TypedData{}, err } - // Use first message for fee payer and type inference - msg := msgs[0] - signerInfo := authInfo.SignerInfos[0] - chainID, err := ethermint.ParseChainID(signDoc.ChainId) + chainID, err := types.ParseChainID(signDoc.ChainId) if err != nil { return apitypes.TypedData{}, fmt.Errorf("invalid chain ID passed as argument: %w", err) } @@ -212,11 +190,6 @@ func decodeProtobufSignDoc(signDocBytes []byte) (apitypes.TypedData, error) { Gas: authInfo.Fee.GasLimit, } - feePayer := msg.GetSigners()[0] - feeDelegation := &FeeDelegationOptions{ - FeePayer: feePayer, - } - tip := authInfo.Tip // WrapTxToTypedData expects the payload as an Amino Sign Doc @@ -232,11 +205,8 @@ func decodeProtobufSignDoc(signDocBytes []byte) (apitypes.TypedData, error) { ) typedData, err := WrapTxToTypedData( - protoCodec, chainID.Uint64(), - msg, signBytes, - feeDelegation, ) if err != nil { return apitypes.TypedData{}, err @@ -256,35 +226,24 @@ func validateCodecInit() error { } // validatePayloadMessages ensures that the transaction messages can be represented in an EIP-712 -// encoding by checking that messages exist, are of the same type, and share a single signer. +// encoding by checking that messages exist and share a single signer. func validatePayloadMessages(msgs []sdk.Msg) error { if len(msgs) == 0 { return errors.New("unable to build EIP-712 payload: transaction does contain any messages") } - var msgType string var msgSigner sdk.AccAddress for i, m := range msgs { - t, err := getMsgType(m) - if err != nil { - return err - } - if len(m.GetSigners()) != 1 { return errors.New("unable to build EIP-712 payload: expect exactly 1 signer") } if i == 0 { - msgType = t msgSigner = m.GetSigners()[0] continue } - if t != msgType { - return errors.New("unable to build EIP-712 payload: different types of messages detected") - } - if !msgSigner.Equals(m.GetSigners()[0]) { return errors.New("unable to build EIP-712 payload: multiple signers detected") } @@ -292,23 +251,3 @@ func validatePayloadMessages(msgs []sdk.Msg) error { return nil } - -// getMsgType returns the message type prefix for the given Cosmos SDK Msg -func getMsgType(msg sdk.Msg) (string, error) { - jsonBytes, err := aminoCodec.MarshalJSON(msg) - if err != nil { - return "", err - } - - var jsonMsg aminoMessage - if err := json.Unmarshal(jsonBytes, &jsonMsg); err != nil { - return "", err - } - - // Verify Type was successfully filled in - if jsonMsg.Type == "" { - return "", errors.New("could not decode message: type is missing") - } - - return jsonMsg.Type, nil -} diff --git a/ethereum/eip712/encoding_legacy.go b/ethereum/eip712/encoding_legacy.go new file mode 100644 index 0000000000..f568b1f68d --- /dev/null +++ b/ethereum/eip712/encoding_legacy.go @@ -0,0 +1,280 @@ +// Copyright 2023 Evmos Foundation +// This file is part of Evmos' Ethermint library. +// +// The Ethermint library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The Ethermint library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the Ethermint library. If not, see https://github.com/evmos/ethermint/blob/main/LICENSE +package eip712 + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/cosmos/cosmos-sdk/x/auth/migrations/legacytx" + + sdk "github.com/cosmos/cosmos-sdk/types" + txTypes "github.com/cosmos/cosmos-sdk/types/tx" + + apitypes "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/evmos/ethermint/types" +) + +type aminoMessage struct { + Type string `json:"type"` + Value interface{} `json:"value"` +} + +// LegacyGetEIP712BytesForMsg returns the EIP-712 object bytes for the given SignDoc bytes by decoding the bytes into +// an EIP-712 object, then converting via LegacyWrapTxToTypedData. See https://eips.ethereum.org/EIPS/eip-712 for more. +func LegacyGetEIP712BytesForMsg(signDocBytes []byte) ([]byte, error) { + typedData, err := LegacyGetEIP712TypedDataForMsg(signDocBytes) + if err != nil { + return nil, err + } + + _, rawData, err := apitypes.TypedDataAndHash(typedData) + if err != nil { + return nil, fmt.Errorf("could not get EIP-712 object bytes: %w", err) + } + + return []byte(rawData), nil +} + +// LegacyGetEIP712TypedDataForMsg returns the EIP-712 TypedData representation for either +// Amino or Protobuf encoded signature doc bytes. +func LegacyGetEIP712TypedDataForMsg(signDocBytes []byte) (apitypes.TypedData, error) { + // Attempt to decode as both Amino and Protobuf since the message format is unknown. + // If either decode works, we can move forward with the corresponding typed data. + typedDataAmino, errAmino := legacyDecodeAminoSignDoc(signDocBytes) + if errAmino == nil && isValidEIP712Payload(typedDataAmino) { + return typedDataAmino, nil + } + typedDataProtobuf, errProtobuf := legacyDecodeProtobufSignDoc(signDocBytes) + if errProtobuf == nil && isValidEIP712Payload(typedDataProtobuf) { + return typedDataProtobuf, nil + } + + return apitypes.TypedData{}, fmt.Errorf("could not decode sign doc as either Amino or Protobuf.\n amino: %v\n protobuf: %v", errAmino, errProtobuf) +} + +// legacyDecodeAminoSignDoc attempts to decode the provided sign doc (bytes) as an Amino payload +// and returns a signable EIP-712 TypedData object. +func legacyDecodeAminoSignDoc(signDocBytes []byte) (apitypes.TypedData, error) { + // Ensure codecs have been initialized + if err := validateCodecInit(); err != nil { + return apitypes.TypedData{}, err + } + + var aminoDoc legacytx.StdSignDoc + if err := aminoCodec.UnmarshalJSON(signDocBytes, &aminoDoc); err != nil { + return apitypes.TypedData{}, err + } + + var fees legacytx.StdFee + if err := aminoCodec.UnmarshalJSON(aminoDoc.Fee, &fees); err != nil { + return apitypes.TypedData{}, err + } + + // Validate payload messages + msgs := make([]sdk.Msg, len(aminoDoc.Msgs)) + for i, jsonMsg := range aminoDoc.Msgs { + var m sdk.Msg + if err := aminoCodec.UnmarshalJSON(jsonMsg, &m); err != nil { + return apitypes.TypedData{}, fmt.Errorf("failed to unmarshal sign doc message: %w", err) + } + msgs[i] = m + } + + if err := legacyValidatePayloadMessages(msgs); err != nil { + return apitypes.TypedData{}, err + } + + // Use first message for fee payer and type inference + msg := msgs[0] + + // By convention, the fee payer is the first address in the list of signers. + feePayer := msg.GetSigners()[0] + feeDelegation := &FeeDelegationOptions{ + FeePayer: feePayer, + } + + chainID, err := types.ParseChainID(aminoDoc.ChainID) + if err != nil { + return apitypes.TypedData{}, errors.New("invalid chain ID passed as argument") + } + + typedData, err := LegacyWrapTxToTypedData( + protoCodec, + chainID.Uint64(), + msg, + signDocBytes, + feeDelegation, + ) + if err != nil { + return apitypes.TypedData{}, fmt.Errorf("could not convert to EIP712 representation: %w", err) + } + + return typedData, nil +} + +// legacyDecodeProtobufSignDoc attempts to decode the provided sign doc (bytes) as a Protobuf payload +// and returns a signable EIP-712 TypedData object. +func legacyDecodeProtobufSignDoc(signDocBytes []byte) (apitypes.TypedData, error) { + // Ensure codecs have been initialized + if err := validateCodecInit(); err != nil { + return apitypes.TypedData{}, err + } + + signDoc := &txTypes.SignDoc{} + if err := signDoc.Unmarshal(signDocBytes); err != nil { + return apitypes.TypedData{}, err + } + + authInfo := &txTypes.AuthInfo{} + if err := authInfo.Unmarshal(signDoc.AuthInfoBytes); err != nil { + return apitypes.TypedData{}, err + } + + body := &txTypes.TxBody{} + if err := body.Unmarshal(signDoc.BodyBytes); err != nil { + return apitypes.TypedData{}, err + } + + // Until support for these fields is added, throw an error at their presence + if body.TimeoutHeight != 0 || len(body.ExtensionOptions) != 0 || len(body.NonCriticalExtensionOptions) != 0 { + return apitypes.TypedData{}, errors.New("body contains unsupported fields: TimeoutHeight, ExtensionOptions, or NonCriticalExtensionOptions") + } + + if len(authInfo.SignerInfos) != 1 { + return apitypes.TypedData{}, fmt.Errorf("invalid number of signer infos provided, expected 1 got %v", len(authInfo.SignerInfos)) + } + + // Validate payload messages + msgs := make([]sdk.Msg, len(body.Messages)) + for i, protoMsg := range body.Messages { + var m sdk.Msg + if err := protoCodec.UnpackAny(protoMsg, &m); err != nil { + return apitypes.TypedData{}, fmt.Errorf("could not unpack message object with error %w", err) + } + msgs[i] = m + } + + if err := legacyValidatePayloadMessages(msgs); err != nil { + return apitypes.TypedData{}, err + } + + // Use first message for fee payer and type inference + msg := msgs[0] + + signerInfo := authInfo.SignerInfos[0] + + chainID, err := types.ParseChainID(signDoc.ChainId) + if err != nil { + return apitypes.TypedData{}, fmt.Errorf("invalid chain ID passed as argument: %w", err) + } + + stdFee := &legacytx.StdFee{ + Amount: authInfo.Fee.Amount, + Gas: authInfo.Fee.GasLimit, + } + + feePayer := msg.GetSigners()[0] + feeDelegation := &FeeDelegationOptions{ + FeePayer: feePayer, + } + + tip := authInfo.Tip + + // WrapTxToTypedData expects the payload as an Amino Sign Doc + signBytes := legacytx.StdSignBytes( + signDoc.ChainId, + signDoc.AccountNumber, + signerInfo.Sequence, + body.TimeoutHeight, + *stdFee, + msgs, + body.Memo, + tip, + ) + + typedData, err := LegacyWrapTxToTypedData( + protoCodec, + chainID.Uint64(), + msg, + signBytes, + feeDelegation, + ) + if err != nil { + return apitypes.TypedData{}, err + } + + return typedData, nil +} + +// validatePayloadMessages ensures that the transaction messages can be represented in an EIP-712 +// encoding by checking that messages exist, are of the same type, and share a single signer. +func legacyValidatePayloadMessages(msgs []sdk.Msg) error { + if len(msgs) == 0 { + return errors.New("unable to build EIP-712 payload: transaction does contain any messages") + } + + var msgType string + var msgSigner sdk.AccAddress + + for i, m := range msgs { + t, err := getMsgType(m) + if err != nil { + return err + } + + if len(m.GetSigners()) != 1 { + return errors.New("unable to build EIP-712 payload: expect exactly 1 signer") + } + + if i == 0 { + msgType = t + msgSigner = m.GetSigners()[0] + continue + } + + if t != msgType { + return errors.New("unable to build EIP-712 payload: different types of messages detected") + } + + if !msgSigner.Equals(m.GetSigners()[0]) { + return errors.New("unable to build EIP-712 payload: multiple signers detected") + } + } + + return nil +} + +// getMsgType returns the message type prefix for the given Cosmos SDK Msg +func getMsgType(msg sdk.Msg) (string, error) { + jsonBytes, err := aminoCodec.MarshalJSON(msg) + if err != nil { + return "", err + } + + var jsonMsg aminoMessage + if err := json.Unmarshal(jsonBytes, &jsonMsg); err != nil { + return "", err + } + + // Verify Type was successfully filled in + if jsonMsg.Type == "" { + return "", errors.New("could not decode message: type is missing") + } + + return jsonMsg.Type, nil +} diff --git a/ethereum/eip712/message.go b/ethereum/eip712/message.go new file mode 100644 index 0000000000..0180857be0 --- /dev/null +++ b/ethereum/eip712/message.go @@ -0,0 +1,162 @@ +// Copyright 2023 Evmos Foundation +// This file is part of Evmos' Ethermint library. +// +// The Ethermint library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The Ethermint library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the Ethermint library. If not, see https://github.com/evmos/ethermint/blob/main/LICENSE +package eip712 + +import ( + "fmt" + + errorsmod "cosmossdk.io/errors" + errortypes "github.com/cosmos/cosmos-sdk/types/errors" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type eip712MessagePayload struct { + payload gjson.Result + numPayloadMsgs int + message map[string]interface{} +} + +const ( + payloadMsgsField = "msgs" +) + +// createEIP712MessagePayload generates the EIP-712 message payload +// corresponding to the input data. +func createEIP712MessagePayload(data []byte) (eip712MessagePayload, error) { + basicPayload, err := unmarshalBytesToJSONObject(data) + if err != nil { + return eip712MessagePayload{}, err + } + + payload, numPayloadMsgs, err := FlattenPayloadMessages(basicPayload) + if err != nil { + return eip712MessagePayload{}, errorsmod.Wrap(err, "failed to flatten payload JSON messages") + } + + message, ok := payload.Value().(map[string]interface{}) + if !ok { + return eip712MessagePayload{}, errorsmod.Wrap(errortypes.ErrInvalidType, "failed to parse JSON as map") + } + + messagePayload := eip712MessagePayload{ + payload: payload, + numPayloadMsgs: numPayloadMsgs, + message: message, + } + + return messagePayload, nil +} + +// unmarshalBytesToJSONObject converts a bytestream into +// a JSON object, then makes sure the JSON is an object. +func unmarshalBytesToJSONObject(data []byte) (gjson.Result, error) { + if !gjson.ValidBytes(data) { + return gjson.Result{}, errorsmod.Wrap(errortypes.ErrJSONUnmarshal, "invalid JSON received") + } + + payload := gjson.ParseBytes(data) + + if !payload.IsObject() { + return gjson.Result{}, errorsmod.Wrap(errortypes.ErrJSONUnmarshal, "failed to JSON unmarshal data as object") + } + + return payload, nil +} + +// FlattenPayloadMessages flattens the input payload's messages, representing +// them as key-value pairs of "msg{i}": {Msg}, rather than as an array of Msgs. +// We do this to support messages with different schemas. +func FlattenPayloadMessages(payload gjson.Result) (gjson.Result, int, error) { + flattened := payload + var err error + + msgs, err := getPayloadMessages(payload) + if err != nil { + return gjson.Result{}, 0, err + } + + for i, msg := range msgs { + flattened, err = payloadWithNewMessage(flattened, msg, i) + if err != nil { + return gjson.Result{}, 0, err + } + } + + flattened, err = payloadWithoutMsgsField(flattened) + if err != nil { + return gjson.Result{}, 0, err + } + + return flattened, len(msgs), nil +} + +// getPayloadMessages processes and returns the payload messages as a JSON array. +func getPayloadMessages(payload gjson.Result) ([]gjson.Result, error) { + rawMsgs := payload.Get(payloadMsgsField) + + if !rawMsgs.Exists() { + return nil, errorsmod.Wrap(errortypes.ErrInvalidRequest, "no messages found in payload, unable to parse") + } + + if !rawMsgs.IsArray() { + return nil, errorsmod.Wrap(errortypes.ErrInvalidRequest, "expected type array of messages, cannot parse") + } + + return rawMsgs.Array(), nil +} + +// payloadWithNewMessage returns the updated payload object with the message +// set at the field corresponding to index. +func payloadWithNewMessage(payload gjson.Result, msg gjson.Result, index int) (gjson.Result, error) { + field := msgFieldForIndex(index) + + if payload.Get(field).Exists() { + return gjson.Result{}, errorsmod.Wrapf( + errortypes.ErrInvalidRequest, + "malformed payload received, did not expect to find key at field %v", field, + ) + } + + if !msg.IsObject() { + return gjson.Result{}, errorsmod.Wrapf(errortypes.ErrInvalidRequest, "msg at index %d is not valid JSON: %v", index, msg) + } + + newRaw, err := sjson.SetRaw(payload.Raw, field, msg.Raw) + if err != nil { + return gjson.Result{}, err + } + + return gjson.Parse(newRaw), nil +} + +// msgFieldForIndex returns the payload field for a given message post-flattening. +// e.g. msgs[2] becomes 'msg2' +func msgFieldForIndex(i int) string { + return fmt.Sprintf("msg%d", i) +} + +// payloadWithoutMsgsField returns the updated payload without the "msgs" array +// field, which flattening makes obsolete. +func payloadWithoutMsgsField(payload gjson.Result) (gjson.Result, error) { + newRaw, err := sjson.Delete(payload.Raw, payloadMsgsField) + if err != nil { + return gjson.Result{}, err + } + + return gjson.Parse(newRaw), nil +} diff --git a/ethereum/eip712/preprocess.go b/ethereum/eip712/preprocess.go deleted file mode 100644 index bf5fa28a51..0000000000 --- a/ethereum/eip712/preprocess.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2021 Evmos Foundation -// This file is part of Evmos' Ethermint library. -// -// The Ethermint library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The Ethermint library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the Ethermint library. If not, see https://github.com/evmos/ethermint/blob/main/LICENSE -package eip712 - -import ( - "fmt" - - "github.com/cosmos/cosmos-sdk/client" - codectypes "github.com/cosmos/cosmos-sdk/codec/types" - cosmoskr "github.com/cosmos/cosmos-sdk/crypto/keyring" - "github.com/cosmos/cosmos-sdk/types/tx/signing" - authtx "github.com/cosmos/cosmos-sdk/x/auth/tx" - "github.com/evmos/ethermint/types" -) - -// PreprocessLedgerTx reformats Ledger-signed Cosmos transactions to match the fork expected by Ethermint -// by including the signature in a Web3Tx extension and sending a blank signature in the body. -func PreprocessLedgerTx(chainID string, keyType cosmoskr.KeyType, txBuilder client.TxBuilder) error { - // Only process Ledger transactions - if keyType != cosmoskr.TypeLedger { - return nil - } - - // Init extension builder to set Web3 extension - extensionBuilder, ok := txBuilder.(authtx.ExtensionOptionsTxBuilder) - if !ok { - return fmt.Errorf("cannot cast TxBuilder to ExtensionOptionsTxBuilder") - } - - // Get signatures from TxBuilder - sigs, err := txBuilder.GetTx().GetSignaturesV2() - if err != nil { - return fmt.Errorf("could not get signatures: %w", err) - } - - // Verify single-signer - if len(sigs) != 1 { - return fmt.Errorf("invalid number of signatures, expected 1 and got %v", len(sigs)) - } - - signature := sigs[0] - sigData, ok := signature.Data.(*signing.SingleSignatureData) - if !ok { - return fmt.Errorf("unexpected signature type, expected SingleSignatureData") - } - sigBytes := sigData.Signature - - // Parse Chain ID as big.Int - chainIDInt, err := types.ParseChainID(chainID) - if err != nil { - return fmt.Errorf("could not parse chain id: %w", err) - } - - // Add ExtensionOptionsWeb3Tx extension with signature - var option *codectypes.Any - option, err = codectypes.NewAnyWithValue(&types.ExtensionOptionsWeb3Tx{ - FeePayer: txBuilder.GetTx().FeePayer().String(), - TypedDataChainID: chainIDInt.Uint64(), - FeePayerSig: sigBytes, - }) - if err != nil { - return fmt.Errorf("could not set extension as any: %w", err) - } - - extensionBuilder.SetExtensionOptions(option) - - // Set blank signature with Amino Sign Type - // (Regardless of input signMode, Evmos requires Amino signature type for Ledger) - blankSig := signing.SingleSignatureData{ - SignMode: signing.SignMode_SIGN_MODE_LEGACY_AMINO_JSON, - Signature: nil, - } - sig := signing.SignatureV2{ - PubKey: signature.PubKey, - Data: &blankSig, - Sequence: signature.Sequence, - } - - err = txBuilder.SetSignatures(sig) - if err != nil { - return fmt.Errorf("unable to set signatures on payload: %w", err) - } - - return nil -} diff --git a/ethereum/eip712/preprocess_test.go b/ethereum/eip712/preprocess_test.go deleted file mode 100644 index db80d44e79..0000000000 --- a/ethereum/eip712/preprocess_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package eip712_test - -import ( - "encoding/hex" - "strings" - "testing" - - "cosmossdk.io/math" - "github.com/cosmos/cosmos-sdk/client" - codectypes "github.com/cosmos/cosmos-sdk/codec/types" - "github.com/cosmos/cosmos-sdk/crypto/keyring" - sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/cosmos/cosmos-sdk/types/tx/signing" - "github.com/cosmos/cosmos-sdk/x/auth/ante" - banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" - "github.com/evmos/ethermint/app" - "github.com/evmos/ethermint/encoding" - "github.com/evmos/ethermint/ethereum/eip712" - "github.com/evmos/ethermint/tests" - "github.com/evmos/ethermint/types" - evmtypes "github.com/evmos/ethermint/x/evm/types" - "github.com/stretchr/testify/require" -) - -// Testing Constants -var ( - chainId = "ethermint_9000-1" - ctx = client.Context{}.WithTxConfig( - encoding.MakeConfig(app.ModuleBasics).TxConfig, - ) -) -var feePayerAddress = "ethm17xpfvakm2amg962yls6f84z3kell8c5lthdzgl" - -type TestCaseStruct struct { - txBuilder client.TxBuilder - expectedFeePayer string - expectedGas uint64 - expectedFee math.Int - expectedMemo string - expectedMsg string - expectedSignatureBytes []byte -} - -func TestLedgerPreprocessing(t *testing.T) { - // Update bech32 prefix - sdk.GetConfig().SetBech32PrefixForAccount("ethm", "") - - testCases := []TestCaseStruct{ - createBasicTestCase(t), - createPopulatedTestCase(t), - } - - for _, tc := range testCases { - // Run pre-processing - err := eip712.PreprocessLedgerTx( - chainId, - keyring.TypeLedger, - tc.txBuilder, - ) - - require.NoError(t, err) - - // Verify Web3 extension matches expected - hasExtOptsTx, ok := tc.txBuilder.(ante.HasExtensionOptionsTx) - require.True(t, ok) - require.True(t, len(hasExtOptsTx.GetExtensionOptions()) == 1) - - expectedExt := types.ExtensionOptionsWeb3Tx{ - TypedDataChainID: 9000, - FeePayer: feePayerAddress, - FeePayerSig: tc.expectedSignatureBytes, - } - - expectedExtAny, err := codectypes.NewAnyWithValue(&expectedExt) - require.NoError(t, err) - - actualExtAny := hasExtOptsTx.GetExtensionOptions()[0] - require.Equal(t, expectedExtAny, actualExtAny) - - // Verify signature type matches expected - signatures, err := tc.txBuilder.GetTx().GetSignaturesV2() - require.NoError(t, err) - require.Equal(t, len(signatures), 1) - - txSig := signatures[0].Data.(*signing.SingleSignatureData) - require.Equal(t, txSig.SignMode, signing.SignMode_SIGN_MODE_LEGACY_AMINO_JSON) - - // Verify signature is blank - require.Equal(t, len(txSig.Signature), 0) - - // Verify tx fields are unchanged - tx := tc.txBuilder.GetTx() - - require.Equal(t, tx.FeePayer().String(), tc.expectedFeePayer) - require.Equal(t, tx.GetGas(), tc.expectedGas) - require.Equal(t, tx.GetFee().AmountOf(evmtypes.DefaultParams().EvmDenom), tc.expectedFee) - require.Equal(t, tx.GetMemo(), tc.expectedMemo) - - // Verify message is unchanged - if tc.expectedMsg != "" { - require.Equal(t, len(tx.GetMsgs()), 1) - require.Equal(t, tx.GetMsgs()[0].String(), tc.expectedMsg) - } else { - require.Equal(t, len(tx.GetMsgs()), 0) - } - } -} - -func TestBlankTxBuilder(t *testing.T) { - txBuilder := ctx.TxConfig.NewTxBuilder() - - err := eip712.PreprocessLedgerTx( - chainId, - keyring.TypeLedger, - txBuilder, - ) - - require.Error(t, err) -} - -func TestNonLedgerTxBuilder(t *testing.T) { - txBuilder := ctx.TxConfig.NewTxBuilder() - - err := eip712.PreprocessLedgerTx( - chainId, - keyring.TypeLocal, - txBuilder, - ) - - require.NoError(t, err) -} - -func TestInvalidChainId(t *testing.T) { - txBuilder := ctx.TxConfig.NewTxBuilder() - - err := eip712.PreprocessLedgerTx( - "invalid-chain-id", - keyring.TypeLedger, - txBuilder, - ) - - require.Error(t, err) -} - -func createBasicTestCase(t *testing.T) TestCaseStruct { - t.Helper() - txBuilder := ctx.TxConfig.NewTxBuilder() - - feePayer, err := sdk.AccAddressFromBech32(feePayerAddress) - require.NoError(t, err) - - txBuilder.SetFeePayer(feePayer) - - // Create signature unrelated to payload for testing - signatureHex := strings.Repeat("01", 65) - signatureBytes, err := hex.DecodeString(signatureHex) - require.NoError(t, err) - - _, privKey := tests.NewAddrKey() - sigsV2 := signing.SignatureV2{ - PubKey: privKey.PubKey(), // Use unrelated public key for testing - Data: &signing.SingleSignatureData{ - SignMode: signing.SignMode_SIGN_MODE_DIRECT, - Signature: signatureBytes, - }, - Sequence: 0, - } - - txBuilder.SetSignatures(sigsV2) - return TestCaseStruct{ - txBuilder: txBuilder, - expectedFeePayer: feePayer.String(), - expectedGas: 0, - expectedFee: math.NewInt(0), - expectedMemo: "", - expectedMsg: "", - expectedSignatureBytes: signatureBytes, - } -} - -func createPopulatedTestCase(t *testing.T) TestCaseStruct { - t.Helper() - basicTestCase := createBasicTestCase(t) - txBuilder := basicTestCase.txBuilder - - gasLimit := uint64(200000) - memo := "" - denom := evmtypes.DefaultParams().EvmDenom - feeAmount := math.NewInt(2000) - - txBuilder.SetFeeAmount(sdk.NewCoins( - sdk.NewCoin( - denom, - feeAmount, - ))) - - txBuilder.SetGasLimit(gasLimit) - txBuilder.SetMemo(memo) - - msgSend := banktypes.MsgSend{ - FromAddress: feePayerAddress, - ToAddress: "ethm12luku6uxehhak02py4rcz65zu0swh7wjun6msa", - Amount: sdk.NewCoins( - sdk.NewCoin( - evmtypes.DefaultParams().EvmDenom, - math.NewInt(10000000), - ), - ), - } - - txBuilder.SetMsgs(&msgSend) - - return TestCaseStruct{ - txBuilder: txBuilder, - expectedFeePayer: basicTestCase.expectedFeePayer, - expectedGas: gasLimit, - expectedFee: feeAmount, - expectedMemo: memo, - expectedMsg: msgSend.String(), - expectedSignatureBytes: basicTestCase.expectedSignatureBytes, - } -} diff --git a/ethereum/eip712/types.go b/ethereum/eip712/types.go new file mode 100644 index 0000000000..51d79dd011 --- /dev/null +++ b/ethereum/eip712/types.go @@ -0,0 +1,404 @@ +// Copyright 2023 Evmos Foundation +// This file is part of Evmos' Ethermint library. +// +// The Ethermint library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The Ethermint library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the Ethermint library. If not, see https://github.com/evmos/ethermint/blob/main/LICENSE +package eip712 + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + errorsmod "cosmossdk.io/errors" + errortypes "github.com/cosmos/cosmos-sdk/types/errors" + + "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/tidwall/gjson" +) + +const ( + rootPrefix = "_" + typePrefix = "Type" + + txField = "Tx" + ethBool = "bool" + ethInt64 = "int64" + ethString = "string" + + msgTypeField = "type" + + maxDuplicateTypeDefs = 1000 +) + +// getEIP712Types creates and returns the EIP-712 types +// for the given message payload. +func createEIP712Types(messagePayload eip712MessagePayload) (apitypes.Types, error) { + eip712Types := apitypes.Types{ + "EIP712Domain": { + { + Name: "name", + Type: "string", + }, + { + Name: "version", + Type: "string", + }, + { + Name: "chainId", + Type: "uint256", + }, + { + Name: "verifyingContract", + Type: "string", + }, + { + Name: "salt", + Type: "string", + }, + }, + "Tx": { + {Name: "account_number", Type: "string"}, + {Name: "chain_id", Type: "string"}, + {Name: "fee", Type: "Fee"}, + {Name: "memo", Type: "string"}, + {Name: "sequence", Type: "string"}, + // Note timeout_height was removed because it was not getting filled with the legacyTx + }, + "Fee": { + {Name: "amount", Type: "Coin[]"}, + {Name: "gas", Type: "string"}, + }, + "Coin": { + {Name: "denom", Type: "string"}, + {Name: "amount", Type: "string"}, + }, + } + + for i := 0; i < messagePayload.numPayloadMsgs; i++ { + field := msgFieldForIndex(i) + msg := messagePayload.payload.Get(field) + + if err := addMsgTypesToRoot(eip712Types, field, msg); err != nil { + return nil, err + } + } + + return eip712Types, nil +} + +// addMsgTypesToRoot adds all types for the given message +// to eip712Types, recursively handling object sub-fields. +func addMsgTypesToRoot(eip712Types apitypes.Types, msgField string, msg gjson.Result) (err error) { + defer doRecover(&err) + + if !msg.IsObject() { + return errorsmod.Wrapf(errortypes.ErrInvalidRequest, "message is not valid JSON, cannot parse types") + } + + msgRootType, err := msgRootType(msg) + if err != nil { + return err + } + + msgTypeDef, err := recursivelyAddTypesToRoot(eip712Types, msgRootType, rootPrefix, msg) + if err != nil { + return err + } + + addMsgTypeDefToTxSchema(eip712Types, msgField, msgTypeDef) + + return nil +} + +// msgRootType parses the message and returns the formatted +// type signature corresponding to the message type. +func msgRootType(msg gjson.Result) (string, error) { + msgType := msg.Get(msgTypeField).Str + if msgType == "" { + // .Str is empty for arrays and objects + return "", errorsmod.Wrap(errortypes.ErrInvalidType, "malformed message type value, expected type string") + } + + // Convert e.g. cosmos-sdk/MsgSend to TypeMsgSend + typeTokenized := strings.Split(msgType, "/") + msgSignature := typeTokenized[len(typeTokenized)-1] + rootType := fmt.Sprintf("%v%v", typePrefix, msgSignature) + + return rootType, nil +} + +// addMsgTypeDefToTxSchema adds the message's field-type pairing +// to the Tx schema. +func addMsgTypeDefToTxSchema(eip712Types apitypes.Types, msgField, msgTypeDef string) { + eip712Types[txField] = append(eip712Types[txField], apitypes.Type{ + Name: msgField, + Type: msgTypeDef, + }) +} + +// recursivelyAddTypesToRoot walks all types in the given map +// and recursively adds sub-maps as new types when necessary. +// It adds all type definitions to typeMap, then returns a key +// to the json object's type definition within the map. +func recursivelyAddTypesToRoot( + typeMap apitypes.Types, + rootType string, + prefix string, + payload gjson.Result, +) (string, error) { + typesToAdd := []apitypes.Type{} + + // Must sort the JSON keys for deterministic type generation. + sortedFieldNames, err := sortedJSONKeys(payload) + if err != nil { + return "", errorsmod.Wrap(err, "unable to sort object keys") + } + + typeDef := typeDefForPrefix(prefix, rootType) + + for _, fieldName := range sortedFieldNames { + field := payload.Get(fieldName) + if !field.Exists() { + continue + } + + // Handle array type by unwrapping the first element. + // Note that arrays with multiple types are not supported + // using EIP-712, so we can ignore that case. + isCollection := false + if field.IsArray() { + fieldAsArray := field.Array() + + if len(fieldAsArray) == 0 { + // Arbitrarily add string[] type to handle empty arrays, + // since we cannot access the underlying object. + emptyArrayType := "string[]" + typesToAdd = appendedTypesList(typesToAdd, fieldName, emptyArrayType) + + continue + } + + field = fieldAsArray[0] + isCollection = true + } + + ethType := getEthTypeForJSON(field) + + // Handle JSON primitive types by adding the corresponding + // EIP-712 type to the types schema. + if ethType != "" { + if isCollection { + ethType += "[]" + } + typesToAdd = appendedTypesList(typesToAdd, fieldName, ethType) + + continue + } + + // Handle object types recursively. Note that nested array types are not supported + // in EIP-712, so we can exclude that case. + if field.IsObject() { + fieldPrefix := prefixForSubField(prefix, fieldName) + + fieldTypeDef, err := recursivelyAddTypesToRoot(typeMap, rootType, fieldPrefix, field) + if err != nil { + return "", err + } + + fieldTypeDef = sanitizeTypedef(fieldTypeDef) + if isCollection { + fieldTypeDef += "[]" + } + + typesToAdd = appendedTypesList(typesToAdd, fieldName, fieldTypeDef) + + continue + } + } + + return addTypesToRoot(typeMap, typeDef, typesToAdd) +} + +// sortedJSONKeys returns the sorted JSON keys for the input object, +// to be used for deterministic iteration. +func sortedJSONKeys(json gjson.Result) ([]string, error) { + if !json.IsObject() { + return nil, errorsmod.Wrap(errortypes.ErrInvalidType, "expected JSON map to parse") + } + + jsonMap := json.Map() + + keys := make([]string, len(jsonMap)) + i := 0 + // #nosec G705 for map iteration + for k := range jsonMap { + keys[i] = k + i++ + } + + sort.Slice(keys, func(i, j int) bool { + return strings.Compare(keys[i], keys[j]) > 0 + }) + + return keys, nil +} + +// typeDefForPrefix computes the type definition for the given +// prefix. This value will represent the types key within +// the EIP-712 types map. +func typeDefForPrefix(prefix, rootType string) string { + if prefix == rootPrefix { + return rootType + } + return sanitizeTypedef(prefix) +} + +// appendedTypesList returns an array of Types with a new element +// consisting of name and typeDef. +func appendedTypesList(types []apitypes.Type, name, typeDef string) []apitypes.Type { + return append(types, apitypes.Type{ + Name: name, + Type: typeDef, + }) +} + +// prefixForSubField computes the prefix for a subfield by +// indicating that it's derived from the object associated with prefix. +func prefixForSubField(prefix, fieldName string) string { + return fmt.Sprintf("%s.%s", prefix, fieldName) +} + +// addTypesToRoot attempts to add the types to the root at key +// typeDef and returns the key at which the types are present, +// or an error if they cannot be added. If the typeDef key is a +// duplicate, we return the key corresponding to an identical copy +// if present, without modifying the structure. Otherwise, we insert +// the types at the next available typeDef-{n} field. We do this to +// support identically named payloads with different schemas. +func addTypesToRoot(typeMap apitypes.Types, typeDef string, types []apitypes.Type) (string, error) { + var indexedTypeDef string + + indexAsDuplicate := 0 + + for { + indexedTypeDef = typeDefWithIndex(typeDef, indexAsDuplicate) + existingTypes, foundElement := typeMap[indexedTypeDef] + + // Found identical duplicate, so we can simply return + // the existing type definition. + if foundElement && typesAreEqual(types, existingTypes) { + return indexedTypeDef, nil + } + + // Found no element, so we can create a new one at this index. + if !foundElement { + break + } + + indexAsDuplicate++ + + if indexAsDuplicate == maxDuplicateTypeDefs { + return "", errorsmod.Wrap(errortypes.ErrInvalidRequest, "exceeded maximum number of duplicates for a single type definition") + } + } + + typeMap[indexedTypeDef] = types + + return indexedTypeDef, nil +} + +// typeDefWithIndex creates a duplicate-indexed type definition +// to differentiate between different schemas with the same name. +func typeDefWithIndex(typeDef string, index int) string { + return fmt.Sprintf("%v%d", typeDef, index) +} + +// typesAreEqual compares two apitypes.Type arrays +// and returns a boolean indicating whether they have +// the same values. +// It assumes both arrays are in the same sorted order. +func typesAreEqual(types1 []apitypes.Type, types2 []apitypes.Type) bool { + if len(types1) != len(types2) { + return false + } + + for i := 0; i < len(types1); i++ { + if types1[i].Name != types2[i].Name || types1[i].Type != types2[i].Type { + return false + } + } + + return true +} + +// _.foo_bar.baz -> TypeFooBarBaz +// +// Since Geth does not tolerate complex EIP-712 type names, we need to sanitize +// the inputs. +func sanitizeTypedef(str string) string { + buf := new(bytes.Buffer) + caser := cases.Title(language.English, cases.NoLower) + parts := strings.Split(str, ".") + + for _, part := range parts { + if part == rootPrefix { + buf.WriteString(typePrefix) + continue + } + + subparts := strings.Split(part, "_") + for _, subpart := range subparts { + buf.WriteString(caser.String(subpart)) + } + } + + return buf.String() +} + +// getEthTypeForJSON converts a JSON type to an Ethereum type. +// It returns an empty string for Objects, Arrays, or Null. +// See https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md for more. +func getEthTypeForJSON(json gjson.Result) string { + switch json.Type { + case gjson.True, gjson.False: + return ethBool + case gjson.Number: + return ethInt64 + case gjson.String: + return ethString + case gjson.JSON: + // Array or Object type + return "" + default: + return "" + } +} + +// doRecover attempts to recover in the event of a panic to +// prevent DOS and gracefully handle an error instead. +func doRecover(err *error) { + if r := recover(); r != nil { + if e, ok := r.(error); ok { + e = errorsmod.Wrap(e, "panicked with error") + *err = e + return + } + + *err = fmt.Errorf("%v", r) + } +} diff --git a/go.mod b/go.mod index 360a99de14..8d26b9ac6c 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,8 @@ require ( github.com/stretchr/testify v1.8.2 github.com/tendermint/tendermint v0.34.27 github.com/tendermint/tm-db v0.6.7 + github.com/tidwall/gjson v1.14.4 + github.com/tidwall/sjson v1.2.5 github.com/tyler-smith/go-bip39 v1.1.0 golang.org/x/net v0.9.0 golang.org/x/text v0.9.0 @@ -167,6 +169,8 @@ require ( github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c // indirect github.com/tendermint/go-amino v0.16.0 // indirect github.com/tidwall/btree v1.5.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/tklauser/go-sysconf v0.3.10 // indirect github.com/tklauser/numcpus v0.4.0 // indirect github.com/ulikunitz/xz v0.5.10 // indirect diff --git a/go.sum b/go.sum index 66e6bb0838..1721c4f0f2 100644 --- a/go.sum +++ b/go.sum @@ -1144,9 +1144,16 @@ github.com/tidwall/btree v1.5.0 h1:iV0yVY/frd7r6qGBXfEYs7DH0gTDgrKTrDjS7xt/IyQ= github.com/tidwall/btree v1.5.0/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE= github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.5/go.mod h1:eQsjooMTnV42mHu917E26IogZ2930nFyBQdofk10Udg= github.com/tklauser/go-sysconf v0.3.5/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= diff --git a/gomod2nix.toml b/gomod2nix.toml index 12103525c8..f5b91071dd 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -105,8 +105,8 @@ schema = 3 version = "v1.0.0" hash = "sha256-Qm2aC2vaS8tjtMUbHmlBSagOSqbduEEDwc51qvQaBmA=" [mod."github.com/cosmos/gogoproto"] - version = "v1.4.6" - hash = "sha256-9SCEKBJyK1FHkKyeaBjDT3GURRAtsIoeiDkNwH8a9Co=" + version = "v1.4.7" + hash = "sha256-JGSKV4CMgBGQYR7kZt6QQsVjgLEyAjNzKrtLalJqqVo=" [mod."github.com/cosmos/gorocksdb"] version = "v1.2.0" hash = "sha256-209TcVuXc5s/TcOvNlaQ1HEJAUDTEK3nxPhs+d8TEcY=" @@ -195,8 +195,8 @@ schema = 3 version = "v1.8.1" hash = "sha256-ixcJ2RrK1ZH3YWGQZF9QFBo02NOuLeSp9wJ7gniipgY=" [mod."github.com/go-task/slim-sprig"] - version = "v0.0.0-20210107165309-348f09dbbbc0" - hash = "sha256-jgza4peLzeJlwmMh/c1gNkmtwA9YtSdGaBzBUDXhIZo=" + version = "v0.0.0-20230315185526-52ccab3ef572" + hash = "sha256-D6NjCQbcYC53NdwzyAm4i9M1OjTJIVu4EIt3AD/Vxfg=" [mod."github.com/godbus/dbus"] version = "v0.0.0-20190726142602-4481cbc300e2" hash = "sha256-R7Gb9+Zjy80FbQSDGketoVEqfdOQKuOVTfWRjQ5kxZY=" @@ -301,8 +301,8 @@ schema = 3 version = "v0.15.0" hash = "sha256-9oqKb5Y3hjleOFE2BczbEzLH6q2Jg7kUTP/M8Yk4Ne4=" [mod."github.com/inconshreveable/mousetrap"] - version = "v1.0.1" - hash = "sha256-ZTP9pLgwAAvHYK5A4PqwWCHGt00x5zMSOpCPoomQ3Sg=" + version = "v1.1.0" + hash = "sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=" [mod."github.com/jackpal/go-nat-pmp"] version = "v1.0.2" hash = "sha256-L1D4Yoxnzihs795GZ+Q3AZsFP5c4iqyjTeyrudzPXtw=" @@ -364,11 +364,11 @@ schema = 3 version = "v0.0.5" hash = "sha256-/5i70IkH/qSW5KjGzv8aQNKh9tHoz98tqtL0K2DMFn4=" [mod."github.com/onsi/ginkgo/v2"] - version = "v2.9.1" - hash = "sha256-7yMsN5CMJS/4VU9/Z27/xdbKzZac6/R1JRJDXxGmwiQ=" + version = "v2.9.2" + hash = "sha256-+BCysb26cGinBvJkZZ9mW2BsbhpGvDXw4jJapcNuwaU=" [mod."github.com/onsi/gomega"] - version = "v1.27.4" - hash = "sha256-Fhltm/e6KVXdwfUl65cE8PD1MLyXJa7OH0lg4Bvewv0=" + version = "v1.27.6" + hash = "sha256-nQ252v7WW3UMrx5e+toOpgm5u0qSUoWq4rDyTrDiOQk=" [mod."github.com/pelletier/go-toml/v2"] version = "v2.0.6" hash = "sha256-BxAeApnn5H+OLlH3TXGvIbtC6LmbRnjwbcfT1qMZ4PE=" @@ -427,8 +427,8 @@ schema = 3 version = "v1.5.0" hash = "sha256-Pdp+wC5FWqyJKzyYHb7JCcV9BoJk/sxQw6nLyuLJvuQ=" [mod."github.com/spf13/cobra"] - version = "v1.6.1" - hash = "sha256-80B5HcYdFisz6QLYkTyka7f9Dr6AfcVyPwp3QChoXwU=" + version = "v1.7.0" + hash = "sha256-bom9Zpnz8XPwx9IVF+GAodd3NVQ1dM1Uwxn8sy4Gmzs=" [mod."github.com/spf13/jwalterweatherman"] version = "v1.1.0" hash = "sha256-62BQtqTLF/eVrTOr7pUXE7AiHRjOVC8jQs3/Ehmflfs=" @@ -469,6 +469,18 @@ schema = 3 [mod."github.com/tidwall/btree"] version = "v1.5.0" hash = "sha256-iWll4/+ADLVse3VAHxXYLprILugX/+3u0ZIk0YlLv/Q=" + [mod."github.com/tidwall/gjson"] + version = "v1.14.4" + hash = "sha256-3DS2YNL95wG0qSajgRtIABD32J+oblaKVk8LIw+KSOc=" + [mod."github.com/tidwall/match"] + version = "v1.1.1" + hash = "sha256-M2klhPId3Q3T3VGkSbOkYl/2nLHnsG+yMbXkPkyrRdg=" + [mod."github.com/tidwall/pretty"] + version = "v1.2.0" + hash = "sha256-esRQGsn2Ee/CiySlwyuOICSLdqUkH4P7u8qXszos8Yc=" + [mod."github.com/tidwall/sjson"] + version = "v1.2.5" + hash = "sha256-OYGNolkmL7E1Qs2qrQ3IVpQp5gkcHNU/AB/z2O+Myps=" [mod."github.com/tklauser/go-sysconf"] version = "v0.3.10" hash = "sha256-Zf2NsgM9+HeM949vCce4HQtSbfUiFpeiQ716yKcFyx4=" @@ -533,11 +545,11 @@ schema = 3 version = "v0.0.0-20230110181048-76db0878b65f" hash = "sha256-Jc90F9KU+ZKI0ynF/p3Vwl7TJPb7/MxDFs0ebagty2s=" [mod."google.golang.org/grpc"] - version = "v1.53.0" - hash = "sha256-LkB13k1JaQ7e4nGpCoEA9q4T8oIV0KvkFIDZmHhDr08=" + version = "v1.54.0" + hash = "sha256-2HzpK4s9zAGUv/26wChxbfkX3t4WB1bLM96O5gkQmro=" [mod."google.golang.org/protobuf"] - version = "v1.28.2-0.20220831092852-f930b1dc76e8" - hash = "sha256-li5hXlXwTJ5LIZ8bVki1AZ6UFI2gXHl33JwdX1dOrtM=" + version = "v1.29.1" + hash = "sha256-ilSVvttGSP2xpqpoyQ0/Iuyx1WMiwe6GASKTfoeaqxw=" [mod."gopkg.in/ini.v1"] version = "v1.67.0" hash = "sha256-V10ahGNGT+NLRdKUyRg1dos5RxLBXBk1xutcnquc/+4=" diff --git a/testutil/tx/eip712.go b/testutil/tx/eip712.go index 2aa130c846..73bb9803fd 100644 --- a/testutil/tx/eip712.go +++ b/testutil/tx/eip712.go @@ -42,6 +42,7 @@ import ( type EIP712TxArgs struct { CosmosTxArgs CosmosTxArgs + UseLegacyTypedData bool UseLegacyExtension bool } @@ -121,7 +122,7 @@ func PrepareEIP712CosmosTx( legacyMsg: msgs[0], } - typedData, err := createTypedData(typedDataArgs) + typedData, err := createTypedData(typedDataArgs, args.UseLegacyTypedData) if err != nil { return nil, err } @@ -151,24 +152,27 @@ func PrepareEIP712CosmosTx( } // createTypedData creates the TypedData object corresponding to -// the arguments. -func createTypedData(args typedDataArgs) (apitypes.TypedData, error) { - registry := codectypes.NewInterfaceRegistry() - types.RegisterInterfaces(registry) - cryptocodec.RegisterInterfaces(registry) - evmosCodec := codec.NewProtoCodec(registry) - - feeDelegation := &eip712.FeeDelegationOptions{ - FeePayer: args.legacyFeePayer, - } +// the arguments, using the legacy implementation as specified. +func createTypedData(args typedDataArgs, useLegacy bool) (apitypes.TypedData, error) { + if useLegacy { + registry := codectypes.NewInterfaceRegistry() + types.RegisterInterfaces(registry) + cryptocodec.RegisterInterfaces(registry) + ethermintCodec := codec.NewProtoCodec(registry) + + feeDelegation := &eip712.FeeDelegationOptions{ + FeePayer: args.legacyFeePayer, + } - return eip712.WrapTxToTypedData( - evmosCodec, - args.chainID, - args.legacyMsg, - args.data, - feeDelegation, - ) + return eip712.LegacyWrapTxToTypedData( + ethermintCodec, + args.chainID, + args.legacyMsg, + args.data, + feeDelegation, + ) + } + return eip712.WrapTxToTypedData(args.chainID, args.data) } // signCosmosEIP712Tx signs the cosmos transaction on the txBuilder provided using