diff --git a/.secrets.baseline b/.secrets.baseline index e4e10421..a36338f3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -129,20 +129,20 @@ } ], "results": { - "client/app/privkey_test.go": [ + "client/app/privkey_internal_test.go": [ { "type": "Secret Keyword", - "filename": "client/app/privkey_test.go", + "filename": "client/app/privkey_internal_test.go", "hashed_secret": "8bb6118f8fd6935ad0876a3be34a717d32708ffd", "is_verified": false, - "line_number": 20 + "line_number": 119 }, { "type": "Secret Keyword", - "filename": "client/app/privkey_test.go", + "filename": "client/app/privkey_internal_test.go", "hashed_secret": "d8ecf7db8fc9ec9c31bc5c9ae2929cc599c75f8d", "is_verified": false, - "line_number": 43 + "line_number": 142 } ], "client/app/prompt.go": [ @@ -974,5 +974,5 @@ } ] }, - "generated_at": "2025-03-26T14:30:28Z" + "generated_at": "2025-05-14T05:17:21Z" } diff --git a/client/app/privkey.go b/client/app/privkey.go index 419d3297..7b72fe4d 100644 --- a/client/app/privkey.go +++ b/client/app/privkey.go @@ -8,11 +8,9 @@ import ( "github.com/cometbft/cometbft/crypto" cmtjson "github.com/cometbft/cometbft/libs/json" "github.com/cometbft/cometbft/privval" - "github.com/ethereum/go-ethereum/accounts/keystore" keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4" "github.com/piplabs/story/lib/errors" - "github.com/piplabs/story/lib/k1util" ) // loadPrivVal returns a privval.FilePV by loading either a CometBFT priv validator key or an Ethereum keystore file. @@ -71,23 +69,6 @@ func loadPrivVal(cfg Config) (*privval.FilePV, error) { return resp, nil } -// loadEthKeystore loads an Ethereum keystore file and returns the private key. -// -//nolint:unused //Ignore unused function temporarily -func loadEthKeystore(keystoreFile string, password string) (crypto.PrivKey, error) { - bz, err := os.ReadFile(keystoreFile) - if err != nil { - return nil, errors.Wrap(err, "read keystore file", "path", keystoreFile) - } - - key, err := keystore.DecryptKey(bz, password) - if err != nil { - return nil, errors.Wrap(err, "decrypt keystore file", "path", keystoreFile) - } - - return k1util.StdPrivKeyToComet(key.PrivateKey) -} - // loadCometFilePV loads a CometBFT privval file and returns the private key. func loadCometFilePV(file string) (crypto.PrivKey, error) { bz, err := os.ReadFile(file) diff --git a/client/app/privkey_internal_test.go b/client/app/privkey_internal_test.go new file mode 100644 index 00000000..79f63085 --- /dev/null +++ b/client/app/privkey_internal_test.go @@ -0,0 +1,162 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + cmtconfig "github.com/cometbft/cometbft/config" + k1 "github.com/cometbft/cometbft/crypto/secp256k1" + "github.com/cometbft/cometbft/privval" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + storycfg "github.com/piplabs/story/client/config" + "github.com/piplabs/story/lib/k1util" +) + +func TestLoadPrivVal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmtPrivval bool + cmtPrivState bool + err bool + }{ + { + name: "comet privval and state", + cmtPrivval: true, + cmtPrivState: true, + }, + { + name: "no files", + cmtPrivval: false, + cmtPrivState: false, + err: true, + }, + { + name: "comet privval only", + cmtPrivval: true, + cmtPrivState: false, + err: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + homeDir := t.TempDir() + + // Define the file paths + cmtPrivvalFile := filepath.Join(homeDir, "config", "priv_validator_key.json") + cmtPrivStateFile := filepath.Join(homeDir, "data", "priv_validator_state.json") + + // Ensure the config and data directories exist + require.NoError(t, os.Mkdir(filepath.Dir(cmtPrivvalFile), 0755)) + require.NoError(t, os.Mkdir(filepath.Dir(cmtPrivStateFile), 0755)) + + // Generate the expected private key + privKey, err := crypto.GenerateKey() + require.NoError(t, err) + + // Convert the private key to a comet private key + cmtPrivKey, err := k1util.StdPrivKeyToComet(privKey) + require.NoError(t, err) + + // Write the comet privval file (with non-zero state) + key := privval.NewFilePV(cmtPrivKey, cmtPrivvalFile, cmtPrivStateFile) + err = key.SignVote("chain", &cmtproto.Vote{ + Type: cmtproto.PrecommitType, + }) + require.NoError(t, err) + key.Save() + + // Remove the files if they are not needed + if !tt.cmtPrivval { + require.NoError(t, os.Remove(cmtPrivvalFile)) + } + if !tt.cmtPrivState { + require.NoError(t, os.Remove(cmtPrivStateFile)) + } + + // Setup the config + cfg := Config{ + Config: storycfg.Config{ + HomeDir: homeDir, + }, + Comet: cmtconfig.Config{ + BaseConfig: cmtconfig.BaseConfig{ + RootDir: homeDir, + PrivValidatorKey: "config/priv_validator_key.json", + PrivValidatorState: "data/priv_validator_state.json", + }, + }, + } + + // Run the test + pv, err := loadPrivVal(cfg) + + // Assert the results + if tt.err { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, pv) + require.True(t, pv.Key.PrivKey.Equals(cmtPrivKey)) + } + }) + } +} + +func setupTestEnv(t *testing.T) (string, string, string) { + t.Helper() + + stateFileDir := filepath.Join(t.TempDir(), "stateFileDir") + encFileDir := filepath.Join(t.TempDir(), "encFileDir") + password := "testpassword" + + return stateFileDir, encFileDir, password +} + +func TestEncryptAndDecrypt_Success(t *testing.T) { + stateFileDir, encFileDir, password := setupTestEnv(t) + + pv := privval.NewFilePV(k1.GenPrivKey(), "", stateFileDir) + + // Encryption + err := EncryptAndStoreKey(pv.Key, password, encFileDir) + require.NoError(t, err) + + // Decryption + loadedKey, err := LoadEncryptedPrivKey(password, encFileDir) + require.NoError(t, err) + + assert.Equal(t, pv.Key, loadedKey, "The decrypted key must match the original.") +} + +func TestLoadEncryptedPrivKey_WrongPassword(t *testing.T) { + stateFileDir, encFileDir, password := setupTestEnv(t) + wrongPassword := "wrongpassword" + + pv := privval.NewFilePV(k1.GenPrivKey(), "", stateFileDir) + + // Encryption + err := EncryptAndStoreKey(pv.Key, password, encFileDir) + require.NoError(t, err) + + // Decrypt with wrong password + _, err = LoadEncryptedPrivKey(wrongPassword, encFileDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "wrong password for wallet entered") +} + +func TestLoadEncryptedPrivKey_FileNotFound(t *testing.T) { + _, encFileDir, password := setupTestEnv(t) + + _, err := LoadEncryptedPrivKey(password, encFileDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read enc_priv_key.json file") +} diff --git a/client/app/privkey_test.go b/client/app/privkey_test.go deleted file mode 100644 index b8241356..00000000 --- a/client/app/privkey_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package app_test - -import ( - "path/filepath" - "testing" - - k1 "github.com/cometbft/cometbft/crypto/secp256k1" - "github.com/cometbft/cometbft/privval" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/piplabs/story/client/app" -) - -func setupTestEnv(t *testing.T) (string, string, string) { - t.Helper() - - stateFileDir := filepath.Join(t.TempDir(), "stateFileDir") - encFileDir := filepath.Join(t.TempDir(), "encFileDir") - password := "testpassword" - - return stateFileDir, encFileDir, password -} - -func TestEncryptAndDecrypt_Success(t *testing.T) { - stateFileDir, encFileDir, password := setupTestEnv(t) - - pv := privval.NewFilePV(k1.GenPrivKey(), "", stateFileDir) - - // Encryption - err := app.EncryptAndStoreKey(pv.Key, password, encFileDir) - require.NoError(t, err) - - // Decryption - loadedKey, err := app.LoadEncryptedPrivKey(password, encFileDir) - require.NoError(t, err) - - assert.Equal(t, pv.Key, loadedKey, "The decrypted key must match the original.") -} - -func TestLoadEncryptedPrivKey_WrongPassword(t *testing.T) { - stateFileDir, encFileDir, password := setupTestEnv(t) - wrongPassword := "wrongpassword" - - pv := privval.NewFilePV(k1.GenPrivKey(), "", stateFileDir) - - // Encryption - err := app.EncryptAndStoreKey(pv.Key, password, encFileDir) - require.NoError(t, err) - - // Decrypt with wrong password - _, err = app.LoadEncryptedPrivKey(wrongPassword, encFileDir) - require.Error(t, err) - assert.Contains(t, err.Error(), "wrong password for wallet entered") -} - -func TestLoadEncryptedPrivKey_FileNotFound(t *testing.T) { - _, encFileDir, password := setupTestEnv(t) - - _, err := app.LoadEncryptedPrivKey(password, encFileDir) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to read enc_priv_key.json file") -} diff --git a/client/app/prouter_internal_test.go b/client/app/prouter_internal_test.go new file mode 100644 index 00000000..ead1f855 --- /dev/null +++ b/client/app/prouter_internal_test.go @@ -0,0 +1,364 @@ +package app + +import ( + "context" + "reflect" + "testing" + "unsafe" + + "cosmossdk.io/depinject" + "cosmossdk.io/math" + storetypes "cosmossdk.io/store/types" + + abci "github.com/cometbft/cometbft/abci/types" + cmttypes "github.com/cometbft/cometbft/proto/tendermint/types" + "github.com/cosmos/cosmos-sdk/baseapp" + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdktestutil "github.com/cosmos/cosmos-sdk/testutil" + "github.com/cosmos/cosmos-sdk/types" + moduletestutil "github.com/cosmos/cosmos-sdk/types/module/testutil" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + "github.com/cosmos/cosmos-sdk/types/tx/signing" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + stypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" + + "github.com/piplabs/story/client/x/evmengine/module" + etypes "github.com/piplabs/story/client/x/evmengine/types" + esmodule "github.com/piplabs/story/client/x/evmstaking/module" + "github.com/piplabs/story/lib/errors" + "github.com/piplabs/story/lib/ethclient" + + protov2 "google.golang.org/protobuf/proto" +) + +func createRequest(t *testing.T, txConfig client.TxConfig, msg []types.Msg, isFirst, multipleTx bool) *abci.RequestProcessProposal { + t.Helper() + + b := txConfig.NewTxBuilder() + require.NoError(t, b.SetMsgs(msg...)) + + tx := b.GetTx() + + txBz, err := txConfig.TxEncoder()(tx) + require.NoError(t, err) + + txs := [][]byte{txBz} + if msg == nil { + txs = nil + } + + if multipleTx { + txs = append(txs, txBz) + } + + height := int64(99) + if isFirst { + height = 1 + } + + return &abci.RequestProcessProposal{ + Height: height, + Txs: txs, + ProposedLastCommit: abci.CommitInfo{ + Votes: []abci.VoteInfo{ + {BlockIdFlag: cmttypes.BlockIDFlagCommit, Validator: abci.Validator{Power: 1}}, + }, + }, + } +} + +func TestProcessProposalRouter(t *testing.T) { + executionPayloadMsg := &etypes.MsgExecutionPayload{ + Authority: authtypes.NewModuleAddress(etypes.ModuleName).String(), + } + + tcs := []struct { + name string + first bool + payloadMsgs []types.Msg + accept bool + multipleTx bool + expectedSrvCall int + }{ + { + name: "first empty", + first: true, + accept: true, + expectedSrvCall: 0, + }, + { + name: "first not empty", + payloadMsgs: []types.Msg{executionPayloadMsg}, + first: true, + accept: false, + expectedSrvCall: 0, + }, + { + name: "too many txs", + payloadMsgs: []types.Msg{executionPayloadMsg}, + multipleTx: true, + accept: false, + expectedSrvCall: 0, + }, + { + name: "one payload message", + payloadMsgs: []types.Msg{executionPayloadMsg}, + accept: true, + expectedSrvCall: 1, + }, + { + name: "two payload messages", + payloadMsgs: []types.Msg{executionPayloadMsg, executionPayloadMsg}, + accept: false, + expectedSrvCall: 1, + }, + { + name: "unexpected msg", + payloadMsgs: []types.Msg{&stypes.Delegation{}}, + accept: false, + expectedSrvCall: 0, + }, + { + name: "invalid tx - authority", + payloadMsgs: []types.Msg{ + &etypes.MsgExecutionPayload{ + Authority: authtypes.NewModuleAddress("test").String(), + }, + }, + accept: false, + expectedSrvCall: 0, + }, + { + name: "invalid payload", + payloadMsgs: []types.Msg{ + &etypes.MsgExecutionPayload{ + Authority: authtypes.NewModuleAddress(etypes.ModuleName).String(), + ExecutionPayload: []byte("invalid payload"), + }, + }, + accept: false, + expectedSrvCall: 1, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + key := storetypes.NewKVStoreKey("test") + ctx := sdktestutil.DefaultContext(key, storetypes.NewTransientStoreKey("test_key")) + + srv := &mockServer{} + encCfg := moduletestutil.MakeTestEncodingConfig(module.AppModuleBasic{}, esmodule.AppModuleBasic{}) + + engineCl := struct { + ethclient.EngineClient + }{} + depCfg := depinject.Configs(DepConfig(), depinject.Supply(newSDKLogger(ctx), engineCl)) + require.NoError(t, depinject.Inject(depCfg, []any{&encCfg.InterfaceRegistry, &encCfg.Codec, &encCfg.TxConfig}...)) + + txConfig := encCfg.TxConfig + + router := baseapp.NewMsgServiceRouter() + router.SetInterfaceRegistry(encCfg.InterfaceRegistry) + etypes.RegisterMsgServiceServer(router, srv) + + handler := makeProcessProposalHandler(router, txConfig) + + newReq := createRequest(t, txConfig, tc.payloadMsgs, tc.first, tc.multipleTx) + + res, err := handler(ctx, newReq) + require.NoError(t, err) + require.Equal(t, tc.expectedSrvCall, srv.payload) + if tc.accept { + require.Equal(t, abci.ResponseProcessProposal_ACCEPT, res.Status) + } else { + require.Equal(t, abci.ResponseProcessProposal_REJECT, res.Status) + } + }) + } +} + +var _ types.Tx = &mockTx{} + +type mockTx struct{} + +func NewMockTx() *mockTx { + return &mockTx{} +} + +func (m *mockTx) GetMsgs() []types.Msg { + return nil +} + +func (m *mockTx) GetMsgsV2() ([]protov2.Message, error) { + return nil, nil +} + +func TestValidateTx(t *testing.T) { + authority := authtypes.NewModuleAddress(etypes.ModuleName).String() + + tcs := []struct { + name string + msgs []types.Msg + isNotSigningTx bool + callback func(client.TxBuilder) + expectedErr string + }{ + { + name: "invalid signing tx", + isNotSigningTx: true, + msgs: []types.Msg{&etypes.MsgExecutionPayload{Authority: authority}}, + expectedErr: "invalid standard tx message", + }, + { + name: "valid payload message", + msgs: []types.Msg{&etypes.MsgExecutionPayload{Authority: authority}}, + }, + { + name: "memo not empty", + msgs: []types.Msg{&etypes.MsgExecutionPayload{Authority: authority}}, + callback: func(b client.TxBuilder) { + b.SetMemo("memo") + }, + expectedErr: "disallowed memo in tx", + }, + { + name: "fee not empty", + msgs: []types.Msg{&etypes.MsgExecutionPayload{Authority: authority}}, + callback: func(b client.TxBuilder) { + b.SetFeeAmount(types.Coins{types.NewCoin(types.DefaultBondDenom, math.NewInt(1))}) + }, + expectedErr: "disallowed fee in tx", + }, + { + name: "signatures v2 not empty", + msgs: []types.Msg{&etypes.MsgExecutionPayload{Authority: authority}}, + callback: func(b client.TxBuilder) { + sig := signing.SignatureV2{ + PubKey: &secp256k1.PubKey{ + Key: []byte("key"), + }, + Data: &signing.SingleSignatureData{ + SignMode: signing.SignMode_SIGN_MODE_DIRECT, + Signature: []byte("sig"), + }, + Sequence: 0, + } + + _ = b.SetSignatures(sig) + }, + expectedErr: "disallowed signatures in tx", + }, + { + name: "fee granter not empty", + msgs: []types.Msg{&etypes.MsgExecutionPayload{Authority: authority}}, + callback: func(b client.TxBuilder) { + b.SetFeeGranter(authtypes.NewModuleAddress("granter")) + }, + expectedErr: "disallowed fee granter in tx", + }, + { + name: "tip not empty", + msgs: []types.Msg{&etypes.MsgExecutionPayload{Authority: authority}}, + callback: func(b client.TxBuilder) { + var tip = &txtypes.Tip{ + Amount: types.NewCoins(), + Tipper: "invalid tip", + } + wrappedTx := b.GetTx() + + wrappedTxField := reflect.ValueOf(wrappedTx).Elem() + txField := wrappedTxField.FieldByName("tx").Elem() + authInfoField := txField.FieldByName("AuthInfo").Elem() + tipField := authInfoField.FieldByName("Tip") + fieldPtr := unsafe.Pointer(tipField.UnsafeAddr()) + fieldVal := reflect.NewAt(tipField.Type(), fieldPtr).Elem() + fieldVal.Set(reflect.ValueOf(tip)) + }, + expectedErr: "disallowed tip in tx", + }, + { + name: "signatures not empty", + msgs: []types.Msg{&etypes.MsgExecutionPayload{Authority: authority}}, + callback: func(b client.TxBuilder) { + signatures := [][]byte{ + []byte("invalid signatures"), + } + wrappedTx := b.GetTx() + + wrappedTxField := reflect.ValueOf(wrappedTx).Elem() + txField := wrappedTxField.FieldByName("tx").Elem() + signaturesField := txField.FieldByName("Signatures") + fieldPtr := unsafe.Pointer(signaturesField.UnsafeAddr()) + fieldVal := reflect.NewAt(signaturesField.Type(), fieldPtr).Elem() + fieldVal.Set(reflect.ValueOf(signatures)) + }, + expectedErr: "disallowed signatures in tx", + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + key := storetypes.NewKVStoreKey("test") + ctx := sdktestutil.DefaultContext(key, storetypes.NewTransientStoreKey("test_key")) + + srv := &mockServer{} + encCfg := moduletestutil.MakeTestEncodingConfig(module.AppModuleBasic{}, esmodule.AppModuleBasic{}) + + engineCl := struct { + ethclient.EngineClient + }{} + depCfg := depinject.Configs(DepConfig(), depinject.Supply(newSDKLogger(ctx), engineCl)) + require.NoError(t, depinject.Inject(depCfg, []any{&encCfg.InterfaceRegistry, &encCfg.Codec, &encCfg.TxConfig}...)) + + txConfig := encCfg.TxConfig + + router := baseapp.NewMsgServiceRouter() + router.SetInterfaceRegistry(encCfg.InterfaceRegistry) + etypes.RegisterMsgServiceServer(router, srv) + + b := txConfig.NewTxBuilder() + if tc.callback != nil { + tc.callback(b) + } + + require.NoError(t, b.SetMsgs(tc.msgs...)) + + var tx types.Tx + if tc.isNotSigningTx { + tx = NewMockTx() + } else { + tx = b.GetTx() + } + err := validateTx(tx) + + if tc.expectedErr != "" { + require.ErrorContains(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} + +var _ etypes.MsgServiceServer = &mockServer{} + +type mockServer struct { + etypes.MsgServiceServer + payload int +} + +func (s *mockServer) ExecutionPayload(_ context.Context, payload *etypes.MsgExecutionPayload) (*etypes.ExecutionPayloadResponse, error) { + s.payload++ + + if payload.ExecutionPayload == nil { + return &etypes.ExecutionPayloadResponse{}, nil + } + + if err := etypes.ValidateExecPayload(payload); err != nil { + return nil, errors.New("invalid execution payload") + } + + return &etypes.ExecutionPayloadResponse{}, nil +}