diff --git a/CHANGELOG.md b/CHANGELOG.md index 403ce5e9..c1510246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ * (cli) [#1230](https://github.com/evmos/ethermint/pull/1230) Remove redundant positional height parameter from feemarket's query cli. * (ante) [#1289](https://github.com/evmos/ethermint/pull/1289) Change the fallback tx priority mechanism to be based on gas price. * (test) [#1311](https://github.com/evmos/ethermint/pull/1311) add integration test for the rollback cmd +* (ledger) [#1277](https://github.com/evmos/ethermint/pull/1277) Add Ledger preprocessing transaction hook for EIP-712-signed Cosmos payloads. * (rpc) [#1296](https://github.com/evmos/ethermint/pull/1296) add backend blocks.go unit tests. ### Bug Fixes diff --git a/ethereum/eip712/preprocess.go b/ethereum/eip712/preprocess.go new file mode 100644 index 00000000..d79fc8b2 --- /dev/null +++ b/ethereum/eip712/preprocess.go @@ -0,0 +1,83 @@ +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 new file mode 100644 index 00000000..603454c2 --- /dev/null +++ b/ethereum/eip712/preprocess_test.go @@ -0,0 +1,220 @@ +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" +var 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, + } +}