diff --git a/testutil/e2e/app.go b/testutil/e2e/app.go new file mode 100644 index 000000000..65471f3c4 --- /dev/null +++ b/testutil/e2e/app.go @@ -0,0 +1,164 @@ +package e2e + +import ( + "context" + "errors" + "net" + "net/http" + "sync" + "testing" + + comettypes "github.com/cometbft/cometbft/types" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + "github.com/cosmos/cosmos-sdk/types/module" + "github.com/gorilla/websocket" + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/grpc-ecosystem/grpc-gateway/utilities" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + coretypes "github.com/cometbft/cometbft/rpc/core/types" + + "github.com/pokt-network/poktroll/testutil/integration" + "github.com/pokt-network/poktroll/testutil/testclient" +) + +// E2EApp wraps an integration.App and provides both gRPC and WebSocket servers for end-to-end testing +type E2EApp struct { + *integration.App + grpcServer *grpc.Server + grpcListener net.Listener + wsServer *http.Server + wsListener net.Listener + wsUpgrader websocket.Upgrader + wsConnMutex sync.RWMutex + wsConnections map[*websocket.Conn]map[string]struct{} // maps connections to their subscribed event queries + resultEventChan chan *coretypes.ResultEvent +} + +// NewE2EApp creates a new E2EApp instance with integration.App, gRPC, and WebSocket servers +func NewE2EApp(t *testing.T, opts ...integration.IntegrationAppOptionFn) *E2EApp { + t.Helper() + ctx := context.Background() + + // Initialize and start gRPC server + creds := insecure.NewCredentials() + grpcServer := grpc.NewServer(grpc.Creds(creds)) + mux := runtime.NewServeMux() + + rootPattern, err := runtime.NewPattern( + 1, + []int{int(utilities.OpLitPush), int(utilities.OpNop)}, + []string{""}, + "", + ) + require.NoError(t, err) + + // Create the integration app + opts = append(opts, integration.WithGRPCServer(grpcServer)) + app := integration.NewCompleteIntegrationApp(t, opts...) + app.RegisterGRPCServer(grpcServer) + + flagSet := testclient.NewFlagSet(t, "tcp://127.0.0.1:42070") + keyRing := keyring.NewInMemory(app.GetCodec()) + clientCtx := testclient.NewLocalnetClientCtx(t, flagSet).WithKeyring(keyRing) + + // Register the handler with the mux + client, err := grpc.NewClient("127.0.0.1:42069", grpc.WithInsecure()) + require.NoError(t, err) + + for _, mod := range app.GetModuleManager().Modules { + mod.(module.AppModuleBasic).RegisterGRPCGatewayRoutes(clientCtx, mux) + } + + // Create listeners for gRPC, WebSocket, and HTTP + grpcListener, err := net.Listen("tcp", "127.0.0.1:42069") + require.NoError(t, err, "failed to create gRPC listener") + + wsListener, err := net.Listen("tcp", "127.0.0.1:6969") + require.NoError(t, err, "failed to create WebSocket listener") + + e2eApp := &E2EApp{ + App: app, + grpcListener: grpcListener, + grpcServer: grpcServer, + wsListener: wsListener, + wsConnections: make(map[*websocket.Conn]map[string]struct{}), + wsUpgrader: websocket.Upgrader{}, + resultEventChan: make(chan *coretypes.ResultEvent), + } + + mux.Handle(http.MethodPost, rootPattern, newPostHandler(ctx, client, e2eApp)) + + go func() { + if err := e2eApp.grpcServer.Serve(grpcListener); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + } + }() + + // Initialize and start WebSocket server + e2eApp.wsServer = newWebSocketServer(e2eApp) + go func() { + if err := e2eApp.wsServer.Serve(wsListener); err != nil && errors.Is(err, http.ErrServerClosed) { + if !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + } + }() + + // Initialize and start HTTP server + go func() { + if err := http.ListenAndServe("127.0.0.1:42070", mux); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + } + }() + + // Start event handling + go e2eApp.handleResultEvents(t) + + return e2eApp +} + +// Close gracefully shuts down the E2EApp and its servers +func (app *E2EApp) Close() error { + app.grpcServer.GracefulStop() + if err := app.wsServer.Close(); err != nil { + return err + } + + close(app.resultEventChan) + + return nil +} + +// GetClientConn returns a gRPC client connection to the E2EApp's gRPC server. +func (app *E2EApp) GetClientConn() (*grpc.ClientConn, error) { + return grpc.NewClient( + app.grpcListener.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) +} + +// GetWSEndpoint returns the WebSocket endpoint URL +func (app *E2EApp) GetWSEndpoint() string { + return "ws://" + app.wsListener.Addr().String() + "/websocket" +} + +// TODO_IN_THIS_COMMIT: godoc & move... +func (app *E2EApp) GetCometBlockID() comettypes.BlockID { + lastBlockID := app.GetSdkCtx().BlockHeader().LastBlockId + partSetHeader := lastBlockID.GetPartSetHeader() + + return comettypes.BlockID{ + Hash: lastBlockID.GetHash(), + PartSetHeader: comettypes.PartSetHeader{ + Total: partSetHeader.GetTotal(), + Hash: partSetHeader.GetHash(), + }, + } +} diff --git a/testutil/e2e/app_test.go b/testutil/e2e/app_test.go new file mode 100644 index 000000000..2702cc8db --- /dev/null +++ b/testutil/e2e/app_test.go @@ -0,0 +1,110 @@ +package e2e + +import ( + "testing" + + "cosmossdk.io/depinject" + "cosmossdk.io/math" + comethttp "github.com/cometbft/cometbft/rpc/client/http" + cosmostx "github.com/cosmos/cosmos-sdk/client/tx" + "github.com/cosmos/cosmos-sdk/crypto/hd" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + cosmostypes "github.com/cosmos/cosmos-sdk/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" + + "github.com/pokt-network/poktroll/app/volatile" + "github.com/pokt-network/poktroll/pkg/client/block" + "github.com/pokt-network/poktroll/pkg/client/events" + "github.com/pokt-network/poktroll/pkg/client/query" + "github.com/pokt-network/poktroll/pkg/client/tx" + txtypes "github.com/pokt-network/poktroll/pkg/client/tx/types" + "github.com/pokt-network/poktroll/testutil/testclient" + gatewaytypes "github.com/pokt-network/poktroll/x/gateway/types" + sharedtypes "github.com/pokt-network/poktroll/x/shared/types" +) + +func TestNewE2EApp(t *testing.T) { + app := NewE2EApp(t) + + // Construct dependencies... + keyRing := keyring.NewInMemory(app.GetCodec()) + rec, err := keyRing.NewAccount( + "gateway2", + "suffer wet jelly furnace cousin flip layer render finish frequent pledge feature economy wink like water disease final erase goat include apple state furnace", + "", + cosmostypes.FullFundraiserPath, + hd.Secp256k1, + ) + require.NoError(t, err) + + gateway2Addr, err := rec.GetAddress() + require.NoError(t, err) + + blockQueryClient, err := comethttp.New("tcp://127.0.0.1:42070", "/websocket") + require.NoError(t, err) + + grpcConn, err := app.GetClientConn() + require.NoError(t, err) + + deps := depinject.Supply(grpcConn, blockQueryClient) + + sharedQueryClient, err := query.NewSharedQuerier(deps) + require.NoError(t, err) + + sharedParams, err := sharedQueryClient.GetParams(app.GetSdkCtx()) + require.NoError(t, err) + require.Equal(t, sharedtypes.DefaultParams(), *sharedParams) + + eventsQueryClient := events.NewEventsQueryClient("ws://127.0.0.1:6969/websocket") + deps = depinject.Configs(deps, depinject.Supply(eventsQueryClient)) + blockClient, err := block.NewBlockClient(app.GetSdkCtx(), deps) + require.NoError(t, err) + + flagSet := testclient.NewFlagSet(t, "tcp://127.0.0.1:42070") + // DEV_NOTE: DO NOT use the clientCtx as a grpc.ClientConn as it bypasses E2EApp integrations. + clientCtx := testclient.NewLocalnetClientCtx(t, flagSet).WithKeyring(keyRing) + + txFactory, err := cosmostx.NewFactoryCLI(clientCtx, flagSet) + require.NoError(t, err) + + deps = depinject.Configs(deps, depinject.Supply(txtypes.Context(clientCtx), txFactory)) + + txContext, err := tx.NewTxContext(deps) + require.NoError(t, err) + + deps = depinject.Configs(deps, depinject.Supply(blockClient, txContext)) + txClient, err := tx.NewTxClient(app.GetSdkCtx(), deps, tx.WithSigningKeyName("gateway2")) + require.NoError(t, err) + + // Assert that no gateways are staked. + gatewayQueryClient := gatewaytypes.NewQueryClient(grpcConn) + allGatewaysRes, err := gatewayQueryClient.AllGateways(app.GetSdkCtx(), &gatewaytypes.QueryAllGatewaysRequest{}) + require.Equal(t, 0, len(allGatewaysRes.Gateways)) + + // Fund gateway2 account. + _, err = app.RunMsg(t, &banktypes.MsgSend{ + FromAddress: app.GetFaucetBech32(), + ToAddress: gateway2Addr.String(), + Amount: cosmostypes.NewCoins(cosmostypes.NewInt64Coin(volatile.DenomuPOKT, 10000000000)), + }) + require.NoError(t, err) + + // Stake gateway2. + eitherErr := txClient.SignAndBroadcast( + app.GetSdkCtx(), + gatewaytypes.NewMsgStakeGateway( + "pokt15w3fhfyc0lttv7r585e2ncpf6t2kl9uh8rsnyz", + cosmostypes.NewCoin(volatile.DenomuPOKT, math.NewInt(100000001)), + ), + ) + + err, errCh := eitherErr.SyncOrAsyncError() + require.NoError(t, err) + require.NoError(t, <-errCh) + + // Assert that only gateway2 is staked. + allGatewaysRes, err = gatewayQueryClient.AllGateways(app.GetSdkCtx(), &gatewaytypes.QueryAllGatewaysRequest{}) + require.Equal(t, 1, len(allGatewaysRes.Gateways)) + require.Equal(t, "pokt15w3fhfyc0lttv7r585e2ncpf6t2kl9uh8rsnyz", allGatewaysRes.Gateways[0].Address) +} diff --git a/testutil/e2e/comet.go b/testutil/e2e/comet.go new file mode 100644 index 000000000..66eaa414f --- /dev/null +++ b/testutil/e2e/comet.go @@ -0,0 +1,266 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/cometbft/cometbft/abci/types" + coretypes "github.com/cometbft/cometbft/rpc/core/types" + rpctypes "github.com/cometbft/cometbft/rpc/jsonrpc/types" + comettypes "github.com/cometbft/cometbft/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + gogogrpc "github.com/cosmos/gogoproto/grpc" + "github.com/grpc-ecosystem/grpc-gateway/runtime" +) + +// TODO_IN_THIS_COMMIT: godoc... +type CometBFTMethod string + +// TODO_IN_THIS_COMMIT: godoc... +type ServiceMethodUri string + +const ( + abciQueryMethod = CometBFTMethod("abci_query") + broadcastTxSyncMethod = CometBFTMethod("broadcast_tx_sync") + broadcastTxAsyncMethod = CometBFTMethod("broadcast_tx_async") + broadcastTxCommitMethod = CometBFTMethod("broadcast_tx_commit") + blockMethod = CometBFTMethod("block") + + authAccountQueryUri = ServiceMethodUri("/cosmos.auth.v1beta1.Query/Account") +) + +// handleABCIQuery handles the actual ABCI query logic +func newPostHandler( + ctx context.Context, + client gogogrpc.ClientConn, + app *E2EApp, +) runtime.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) { + // DEV_NOTE: http.Error() automatically sets the Content-Type header to "text/plain". + w.Header().Set("Content-Type", "application/json") + + // Read and log request body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error reading request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Parse JSON-RPC request + var req rpctypes.RPCRequest + if err = json.Unmarshal(body, &req); err != nil { + writeErrorResponseFromErr(w, req, err) + return + } + + params := make(map[string]json.RawMessage) + if err = json.Unmarshal(req.Params, ¶ms); err != nil { + writeErrorResponseFromErr(w, req, err) + return + } + + response := new(rpctypes.RPCResponse) + switch CometBFTMethod(req.Method) { + case abciQueryMethod: + response, err = app.handleAbciQuery(ctx, client, req, params) + if err != nil { + *response = rpctypes.NewRPCErrorResponse(req.ID, 500, err.Error(), "") + } + case broadcastTxSyncMethod, broadcastTxAsyncMethod, broadcastTxCommitMethod: + response, err = app.handleBroadcastTx(req, params) + if err != nil { + *response = rpctypes.NewRPCErrorResponse(req.ID, 500, err.Error(), "") + } + case blockMethod: + response, err = app.handleBlock(ctx, client, req, params) + if err != nil { + *response = rpctypes.NewRPCErrorResponse(req.ID, 500, err.Error(), "") + } + default: + *response = rpctypes.NewRPCErrorResponse(req.ID, 500, "unsupported method", string(req.Params)) + } + + if err = json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +// TODO_IN_THIS_COMMIT: godoc... +func (app *E2EApp) handleAbciQuery( + ctx context.Context, + client gogogrpc.ClientConn, + req rpctypes.RPCRequest, + params map[string]json.RawMessage, +) (*rpctypes.RPCResponse, error) { + var ( + resData []byte + height int64 + ) + + pathRaw, hasPath := params["path"] + if !hasPath { + return nil, fmt.Errorf("missing path param: %s", string(req.Params)) + } + + var path string + if err := json.Unmarshal(pathRaw, &path); err != nil { + return nil, err + } + + switch ServiceMethodUri(path) { + case authAccountQueryUri: + dataRaw, hasData := params["data"] + if !hasData { + return nil, fmt.Errorf("missing data param: %s", string(req.Params)) + } + + data, err := hex.DecodeString(string(bytes.Trim(dataRaw, `"`))) + if err != nil { + return nil, err + } + + queryReq := new(authtypes.QueryAccountRequest) + if err = queryReq.Unmarshal(data); err != nil { + return nil, err + } + + var height int64 + heightRaw, hasHeight := params["height"] + if hasHeight { + if err = json.Unmarshal(bytes.Trim(heightRaw, `"`), &height); err != nil { + return nil, err + } + } + + queryRes := new(authtypes.QueryAccountResponse) + if err = client.Invoke(ctx, path, queryReq, queryRes); err != nil { + return nil, err + } + + resData, err = queryRes.Marshal() + if err != nil { + return nil, err + } + } + + abciQueryRes := coretypes.ResultABCIQuery{ + Response: types.ResponseQuery{ + Value: resData, + Height: height, + }, + } + + res := rpctypes.NewRPCSuccessResponse(req.ID, abciQueryRes) + return &res, nil +} + +// TODO_IN_THIS_COMMIT: godoc... +func (app *E2EApp) handleBroadcastTx( + req rpctypes.RPCRequest, + params map[string]json.RawMessage, +) (*rpctypes.RPCResponse, error) { + var txBz []byte + txRaw, hasTx := params["tx"] + if !hasTx { + return nil, fmt.Errorf("missing tx param: %s", string(req.Params)) + } + if err := json.Unmarshal(txRaw, &txBz); err != nil { + return nil, err + } + + // TODO_CONSIDERATION: more correct implementation of the different + // broadcast_tx methods (i.e. sync, async, commit) is a matter of + // the sequencing of the following: + // - calling the finalize block ABCI method + // - returning the JSON-RPC response + // - emitting websocket event + + _, finalizeBlockRes, err := app.RunTx(nil, txBz) + if err != nil { + return nil, err + } + + go func() { + // Simulate 1 second block production delay. + time.Sleep(time.Second * 1) + + // TODO_IMPROVE: If we want/need to support multiple txs per + // block in the future, this will have to be refactored. + app.EmitWSEvents(finalizeBlockRes, txBz) + }() + + // DEV_NOTE: There SHOULD ALWAYS be exactly one tx result so long as + // we're finalizing one tx at a time (single tx blocks). + txRes := finalizeBlockRes.GetTxResults()[0] + + bcastTxRes := coretypes.ResultBroadcastTx{ + Code: txRes.GetCode(), + Data: txRes.GetData(), + Log: txRes.GetLog(), + Codespace: txRes.GetCodespace(), + Hash: comettypes.Tx(txBz).Hash(), + } + + res := rpctypes.NewRPCSuccessResponse(req.ID, bcastTxRes) + return &res, nil +} + +// TODO_IN_THIS_COMMIT: godoc... +func (app *E2EApp) handleBlock( + ctx context.Context, + client gogogrpc.ClientConn, + req rpctypes.RPCRequest, + params map[string]json.RawMessage, +) (*rpctypes.RPCResponse, error) { + resultBlock := coretypes.ResultBlock{ + BlockID: app.GetCometBlockID(), + Block: &comettypes.Block{ + Header: comettypes.Header{ + //Version: version.Consensus{}, + ChainID: "poktroll-test", + Height: app.GetSdkCtx().BlockHeight(), + Time: time.Now(), + LastBlockID: app.GetCometBlockID(), + //LastCommitHash: nil, + //DataHash: nil, + //ValidatorsHash: nil, + //NextValidatorsHash: nil, + //ConsensusHash: nil, + //AppHash: nil, + //LastResultsHash: nil, + //EvidenceHash: nil, + //ProposerAddress: nil, + }, + //Data: comettypes.Data{}, + //Evidence: comettypes.EvidenceData{}, + //LastCommit: nil, + }, + } + res := rpctypes.NewRPCSuccessResponse(req.ID, resultBlock) + return &res, nil +} + +// TODO_IN_THIS_COMMIT: godoc... +func writeErrorResponseFromErr(w http.ResponseWriter, req rpctypes.RPCRequest, err error) { + var errMsg string + if err != nil { + errMsg = err.Error() + } + writeErrorResponse(w, req, errMsg, "") +} + +// TODO_IN_THIS_COMMIT: godoc... +func writeErrorResponse(w http.ResponseWriter, req rpctypes.RPCRequest, msg, data string) { + errRes := rpctypes.NewRPCErrorResponse(req.ID, 500, msg, data) + if err := json.NewEncoder(w).Encode(errRes); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/testutil/e2e/grpc_server.go b/testutil/e2e/grpc_server.go new file mode 100644 index 000000000..df8caf702 --- /dev/null +++ b/testutil/e2e/grpc_server.go @@ -0,0 +1 @@ +package e2e diff --git a/testutil/e2e/ws_server.go b/testutil/e2e/ws_server.go new file mode 100644 index 000000000..d4fa7baa0 --- /dev/null +++ b/testutil/e2e/ws_server.go @@ -0,0 +1,238 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + abci "github.com/cometbft/cometbft/abci/types" + coretypes "github.com/cometbft/cometbft/rpc/core/types" + rpctypes "github.com/cometbft/cometbft/rpc/jsonrpc/types" + comettypes "github.com/cometbft/cometbft/types" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" +) + +// newWebSocketServer creates and configures a new WebSocket server for the E2EApp +func newWebSocketServer(app *E2EApp) *http.Server { + mux := http.NewServeMux() + mux.HandleFunc("/websocket", app.handleWebSocket) + return &http.Server{Handler: mux} +} + +// handleWebSocket handles incoming WebSocket connections and subscriptions +func (app *E2EApp) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := app.wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + app.wsConnMutex.Lock() + app.wsConnections[conn] = make(map[string]struct{}) + app.wsConnMutex.Unlock() + + go app.handleWebSocketConnection(conn) +} + +// handleWebSocketConnection handles messages from a specific WebSocket connection +func (app *E2EApp) handleWebSocketConnection(conn *websocket.Conn) { + logger := app.Logger().With("method", "handleWebSocketConnection") + + defer func() { + app.wsConnMutex.Lock() + delete(app.wsConnections, conn) + app.wsConnMutex.Unlock() + conn.Close() + }() + + for { + _, message, err := conn.ReadMessage() + if err != nil { + return + } + + var req rpctypes.RPCRequest + if err = json.Unmarshal(message, &req); err != nil { + continue + } + + // Handle subscription requests. + if req.Method == "subscribe" { + var params struct { + Query string `json:"query"` + } + if err = json.Unmarshal(req.Params, ¶ms); err != nil { + continue + } + + app.wsConnMutex.Lock() + app.wsConnections[conn][params.Query] = struct{}{} + app.wsConnMutex.Unlock() + + // Send initial subscription response + resp := rpctypes.RPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + // DEV_NOTE: Query subscription responses are initially empty; data is sent as subsequent events occur. + Result: json.RawMessage("{}"), + } + if err = conn.WriteJSON(resp); err != nil { + logger.Error(fmt.Sprintf("writing JSON-RPC response: %s", err)) + } + } + } +} + +// handleResultEvents coordinates block finalization with WebSocket event broadcasting +func (app *E2EApp) handleResultEvents(t *testing.T) { + t.Helper() + + for event := range app.resultEventChan { + app.wsConnMutex.RLock() + for conn, queries := range app.wsConnections { + // Check if connection is subscribed to this event type + for query := range queries { + queryPartPairs := parseQuery(t, query) + + for queryKey, queryValue := range queryPartPairs { + eventQueryValue, hasQueryKey := event.Events[queryKey] + if !hasQueryKey { + continue + } + + // TODO_IN_THIS_COMMIT: comment explaining 0th index... + if eventQueryValue[0] != strings.Trim(queryValue, "'") { + continue + } + + // DEV_NOTE: An empty request ID is consistent with the cometbft + // implementation and is the reason that we MUST use a distinct + // websocket connection per query; it's not possible to determine + // to which query any given event corresponds. + response := rpctypes.NewRPCSuccessResponse(nil, event) + + if err := conn.WriteJSON(response); err != nil { + app.wsConnMutex.RUnlock() + app.wsConnMutex.Lock() + delete(app.wsConnections, conn) + app.wsConnMutex.Unlock() + app.wsConnMutex.RLock() + } + } + } + } + app.wsConnMutex.RUnlock() + } +} + +// TODO_IN_THIS_COMMIT: godoc and move... +func parseQuery(t *testing.T, query string) map[string]string { + t.Helper() + + queryParts := strings.Split(query, " AND ") + queryPartPairs := make(map[string]string) + for _, queryPart := range queryParts { + queryPartPair := strings.Split(queryPart, "=") + require.Equal(t, 2, len(queryPartPair)) + + queryPartKey := strings.Trim(queryPartPair[0], `" `) + queryPartValue := strings.Trim(queryPartPair[1], `" `) + queryPartPairs[queryPartKey] = queryPartValue + } + + return queryPartPairs +} + +// TODO_IN_THIS_COMMIT: godoc... +func (app *E2EApp) EmitWSEvents(finalizeBlockRes *abci.ResponseFinalizeBlock, txBz []byte) { + events := validateAndStringifyEvents(finalizeBlockRes.GetEvents()) + // DEV_NOTE: see https://github.com/cometbft/cometbft/blob/v0.38.10/types/event_bus.go#L138 + events[comettypes.EventTypeKey] = append(events[comettypes.EventTypeKey], comettypes.EventNewBlock) + + evtDataNewBlock := comettypes.EventDataNewBlock{ + Block: &comettypes.Block{ + Header: comettypes.Header{ + //Version: version.Consensus{}, + ChainID: "poktroll-test", + Height: app.GetSdkCtx().BlockHeight(), + Time: time.Now(), + LastBlockID: app.GetCometBlockID(), + //LastCommitHash: nil, + //DataHash: nil, + //ValidatorsHash: nil, + //NextValidatorsHash: nil, + //ConsensusHash: nil, + //AppHash: nil, + //LastResultsHash: nil, + //EvidenceHash: nil, + //ProposerAddress: nil, + }, + //Data: comettypes.Data{}, + //Evidence: comettypes.EvidenceData{}, + //LastCommit: nil, + }, + BlockID: app.GetCometBlockID(), + ResultFinalizeBlock: *finalizeBlockRes, + } + + // TODO_IN_THIS_COMMIT: comment... + resultEvent := &coretypes.ResultEvent{ + Query: comettypes.EventQueryNewBlock.String(), + Data: evtDataNewBlock, + Events: events, + } + + app.resultEventChan <- resultEvent + + // TODO_IN_THIS_COMMIT: comment... + for idx, txResult := range finalizeBlockRes.GetTxResults() { + events = validateAndStringifyEvents(txResult.GetEvents()) + // DEV_NOTE: see https://github.com/cometbft/cometbft/blob/v0.38.10/types/event_bus.go#L180 + events[comettypes.EventTypeKey] = append(events[comettypes.EventTypeKey], comettypes.EventTx) + events[comettypes.TxHashKey] = append(events[comettypes.TxHashKey], fmt.Sprintf("%X", comettypes.Tx(txBz).Hash())) + events[comettypes.TxHeightKey] = append(events[comettypes.TxHeightKey], fmt.Sprintf("%d", app.GetSdkCtx().BlockHeight())) + + evtDataTx := comettypes.EventDataTx{ + TxResult: abci.TxResult{ + Height: app.GetSdkCtx().BlockHeight(), + Index: uint32(idx), + Tx: txBz, + Result: *txResult, + }, + } + + resultEvent = &coretypes.ResultEvent{ + Query: comettypes.EventQueryTx.String(), + Data: evtDataTx, + Events: events, + } + + app.resultEventChan <- resultEvent + } + + // TODO_IMPROVE: emit individual finalize block & tx result events? +} + +// TODO_IN_THIS_COMMIT: godoc... see: https://github.com/cometbft/cometbft/blob/v0.38.10/types/event_bus.go#L112 +func validateAndStringifyEvents(events []abci.Event) map[string][]string { + result := make(map[string][]string) + for _, event := range events { + if len(event.Type) == 0 { + continue + } + + for _, attr := range event.Attributes { + if len(attr.Key) == 0 { + continue + } + + compositeTag := fmt.Sprintf("%s.%s", event.Type, attr.Key) + result[compositeTag] = append(result[compositeTag], attr.Value) + } + } + + return result +} diff --git a/testutil/integration/app.go b/testutil/integration/app.go index 715c0135b..dee7d75d6 100644 --- a/testutil/integration/app.go +++ b/testutil/integration/app.go @@ -103,7 +103,7 @@ type App struct { txCfg client.TxConfig authority sdk.AccAddress moduleManager module.Manager - queryHelper *baseapp.QueryServiceTestHelper + queryHelper *baseapp.GRPCQueryRouter keyRing keyring.Keyring ringClient crypto.RingClient preGeneratedAccts *testkeyring.PreGeneratedAccountIterator @@ -139,7 +139,7 @@ func NewIntegrationApp( modules map[string]appmodule.AppModule, keys map[string]*storetypes.KVStoreKey, msgRouter *baseapp.MsgServiceRouter, - queryHelper *baseapp.QueryServiceTestHelper, + queryHelper *baseapp.GRPCQueryRouter, opts ...IntegrationAppOptionFn, ) *App { t.Helper() @@ -513,8 +513,8 @@ func NewCompleteIntegrationApp(t *testing.T, opts ...IntegrationAppOptionFn) *Ap ) // Prepare the message & query routers - msgRouter := baseapp.NewMsgServiceRouter() - queryHelper := baseapp.NewQueryServerTestHelper(sdkCtx, registry) + msgRouter := bApp.MsgServiceRouter() + queryHelper := bApp.GRPCQueryRouter() // Prepare the authz keeper and module authzKeeper := authzkeeper.NewKeeper( @@ -563,32 +563,37 @@ func NewCompleteIntegrationApp(t *testing.T, opts ...IntegrationAppOptionFn) *Ap opts..., ) + configurator := module.NewConfigurator(cdc, msgRouter, queryHelper) + for _, mod := range integrationApp.GetModuleManager().Modules { + mod.(module.HasServices).RegisterServices(configurator) + } + // Register the message servers - banktypes.RegisterMsgServer(msgRouter, bankkeeper.NewMsgServerImpl(bankKeeper)) - tokenomicstypes.RegisterMsgServer(msgRouter, tokenomicskeeper.NewMsgServerImpl(tokenomicsKeeper)) - servicetypes.RegisterMsgServer(msgRouter, servicekeeper.NewMsgServerImpl(serviceKeeper)) - sharedtypes.RegisterMsgServer(msgRouter, sharedkeeper.NewMsgServerImpl(sharedKeeper)) - gatewaytypes.RegisterMsgServer(msgRouter, gatewaykeeper.NewMsgServerImpl(gatewayKeeper)) - apptypes.RegisterMsgServer(msgRouter, appkeeper.NewMsgServerImpl(applicationKeeper)) - suppliertypes.RegisterMsgServer(msgRouter, supplierkeeper.NewMsgServerImpl(supplierKeeper)) - prooftypes.RegisterMsgServer(msgRouter, proofkeeper.NewMsgServerImpl(proofKeeper)) - authtypes.RegisterMsgServer(msgRouter, authkeeper.NewMsgServerImpl(accountKeeper)) - sessiontypes.RegisterMsgServer(msgRouter, sessionkeeper.NewMsgServerImpl(sessionKeeper)) - authz.RegisterMsgServer(msgRouter, authzKeeper) + //banktypes.RegisterMsgServer(msgRouter, bankkeeper.NewMsgServerImpl(bankKeeper)) + //tokenomicstypes.RegisterMsgServer(msgRouter, tokenomicskeeper.NewMsgServerImpl(tokenomicsKeeper)) + //servicetypes.RegisterMsgServer(msgRouter, servicekeeper.NewMsgServerImpl(serviceKeeper)) + //sharedtypes.RegisterMsgServer(msgRouter, sharedkeeper.NewMsgServerImpl(sharedKeeper)) + //gatewaytypes.RegisterMsgServer(msgRouter, gatewaykeeper.NewMsgServerImpl(gatewayKeeper)) + //apptypes.RegisterMsgServer(msgRouter, appkeeper.NewMsgServerImpl(applicationKeeper)) + //suppliertypes.RegisterMsgServer(msgRouter, supplierkeeper.NewMsgServerImpl(supplierKeeper)) + //prooftypes.RegisterMsgServer(msgRouter, proofkeeper.NewMsgServerImpl(proofKeeper)) + //authtypes.RegisterMsgServer(msgRouter, authkeeper.NewMsgServerImpl(accountKeeper)) + //sessiontypes.RegisterMsgServer(msgRouter, sessionkeeper.NewMsgServerImpl(sessionKeeper)) + //authz.RegisterMsgServer(msgRouter, authzKeeper) // Register query servers - banktypes.RegisterQueryServer(queryHelper, bankKeeper) - authz.RegisterQueryServer(queryHelper, authzKeeper) - tokenomicstypes.RegisterQueryServer(queryHelper, tokenomicsKeeper) - servicetypes.RegisterQueryServer(queryHelper, serviceKeeper) - sharedtypes.RegisterQueryServer(queryHelper, sharedKeeper) - gatewaytypes.RegisterQueryServer(queryHelper, gatewayKeeper) - apptypes.RegisterQueryServer(queryHelper, applicationKeeper) - suppliertypes.RegisterQueryServer(queryHelper, supplierKeeper) - prooftypes.RegisterQueryServer(queryHelper, proofKeeper) - // TODO_TECHDEBT: What is the query server for authtypes? - // authtypes.RegisterQueryServer(queryHelper, accountKeeper) - sessiontypes.RegisterQueryServer(queryHelper, sessionKeeper) + //banktypes.RegisterQueryServer(queryHelper, bankKeeper) + //authz.RegisterQueryServer(queryHelper, authzKeeper) + //tokenomicstypes.RegisterQueryServer(queryHelper, tokenomicsKeeper) + //servicetypes.RegisterQueryServer(queryHelper, serviceKeeper) + //sharedtypes.RegisterQueryServer(queryHelper, sharedKeeper) + //gatewaytypes.RegisterQueryServer(queryHelper, gatewayKeeper) + //apptypes.RegisterQueryServer(queryHelper, applicationKeeper) + //suppliertypes.RegisterQueryServer(queryHelper, supplierKeeper) + //prooftypes.RegisterQueryServer(queryHelper, proofKeeper) + //// TODO_TECHDEBT: What is the query server for authtypes? + //// authtypes.RegisterQueryServer(queryHelper, accountKeeper) + //sessiontypes.RegisterQueryServer(queryHelper, sessionKeeper) // Need to go to the next block to finalize the genesis and setup. // This has to be after the params are set, as the params are stored in the @@ -661,8 +666,7 @@ func (app *App) GetPreGeneratedAccounts() *testkeyring.PreGeneratedAccountIterat // QueryHelper returns the query helper used by the application that can be // used to submit queries to the application. -func (app *App) QueryHelper() *baseapp.QueryServiceTestHelper { - app.queryHelper.Ctx = *app.sdkCtx +func (app *App) QueryHelper() *baseapp.GRPCQueryRouter { return app.queryHelper } @@ -695,23 +699,9 @@ func (app *App) GetFaucetBech32() string { // returned. In order to run a message, the application must have a handler for it. // These handlers are registered on the application message service router. func (app *App) RunMsgs(t *testing.T, msgs ...sdk.Msg) (txMsgResps []tx.MsgResponse, err error) { - t.Helper() - - // Commit the updated state after the message has been handled. - var finalizeBlockRes *abci.ResponseFinalizeBlock - defer func() { - if _, commitErr := app.Commit(); commitErr != nil { - err = fmt.Errorf("committing state: %w", commitErr) - return - } - - app.nextBlockUpdateCtx() - - // Emit events MUST happen AFTER the context has been updated so that - // events are available on the context for the block after their actions - // were committed (e.g. msgs, begin/end block trigger). - app.emitEvents(t, finalizeBlockRes) - }() + if t != nil { + t.Helper() + } // Package the message into a transaction. txBuilder := app.txCfg.NewTxBuilder() @@ -728,6 +718,40 @@ func (app *App) RunMsgs(t *testing.T, msgs ...sdk.Msg) (txMsgResps []tx.MsgRespo app.logger.Info("Running msg", "msg", msg.String()) } + txMsgResps, _, err = app.RunTx(t, txBz) + if err != nil { + // DEV_NOTE: Intentionally returning and not asserting nil error to improve reusability. + return nil, err + } + + return txMsgResps, nil +} + +// TODO_IN_THIS_COMMIT: godoc... +func (app *App) RunTx(t *testing.T, txBz []byte) ( + txMsgResps []tx.MsgResponse, + finalizeBlockRes *abci.ResponseFinalizeBlock, + err error, +) { + if t != nil { + t.Helper() + } + + // Commit the updated state after the message has been handled. + defer func() { + if _, commitErr := app.Commit(); commitErr != nil { + err = fmt.Errorf("committing state: %w", commitErr) + return + } + + app.nextBlockUpdateCtx() + + // Emit events MUST happen AFTER the context has been updated so that + // events are available on the context for the block after their actions + // were committed (e.g. msgs, begin/end block trigger). + app.emitEvents(t, finalizeBlockRes) + }() + // Finalize the block with the transaction. finalizeBlockReq := &cmtabcitypes.RequestFinalizeBlock{ Height: app.LastBlockHeight() + 1, @@ -741,12 +765,14 @@ func (app *App) RunMsgs(t *testing.T, msgs ...sdk.Msg) (txMsgResps []tx.MsgRespo finalizeBlockRes, err = app.FinalizeBlock(finalizeBlockReq) if err != nil { - return nil, fmt.Errorf("finalizing block: %w", err) + return nil, nil, fmt.Errorf("finalizing block: %w", err) } - // NB: We're batching the messages in a single transaction, so we expect - // a single transaction result. - require.Equal(t, 1, len(finalizeBlockRes.TxResults)) + if t != nil { + // NB: We're batching the messages in a single transaction, so we expect + // a single transaction result. + require.Equal(t, 1, len(finalizeBlockRes.TxResults)) + } // Collect the message responses. Accumulate errors related to message handling // failure. If any message fails, an error will be returned. @@ -759,24 +785,29 @@ func (app *App) RunMsgs(t *testing.T, msgs ...sdk.Msg) (txMsgResps []tx.MsgRespo } txMsgDataBz := txResult.GetData() - require.NotNil(t, txMsgDataBz) + if t != nil { + require.NotNil(t, txMsgDataBz) + } txMsgData := new(cosmostypes.TxMsgData) err = app.GetCodec().Unmarshal(txMsgDataBz, txMsgData) - require.NoError(t, err) + if t != nil { + require.NoError(t, err) + } var txMsgRes tx.MsgResponse err = app.GetCodec().UnpackAny(txMsgData.MsgResponses[0], &txMsgRes) - require.NoError(t, err) - require.NotNil(t, txMsgRes) + if t != nil { + require.NoError(t, err) + require.NotNil(t, txMsgRes) + } else { + return nil, finalizeBlockRes, err + } txMsgResps = append(txMsgResps, txMsgRes) } - if txResultErrs != nil { - return nil, err - } - return txMsgResps, nil + return txMsgResps, finalizeBlockRes, nil } // NextBlocks calls NextBlock numBlocks times @@ -791,13 +822,17 @@ func (app *App) NextBlocks(t *testing.T, numBlocks int) { // emitEvents emits the events from the finalized block such that they are available // via the current context's event manager (i.e. app.GetSdkCtx().EventManager.Events()). func (app *App) emitEvents(t *testing.T, res *abci.ResponseFinalizeBlock) { - t.Helper() + if t != nil { + t.Helper() + } // Emit begin/end blocker events. - for _, event := range res.Events { - testutilevents.QuoteEventMode(&event) - abciEvent := cosmostypes.Event(event) - app.sdkCtx.EventManager().EmitEvent(abciEvent) + if res.Events != nil { + for _, event := range res.Events { + testutilevents.QuoteEventMode(&event) + abciEvent := cosmostypes.Event(event) + app.sdkCtx.EventManager().EmitEvent(abciEvent) + } } // Emit txResult events. @@ -819,6 +854,9 @@ func (app *App) NextBlock(t *testing.T) { Time: app.sdkCtx.BlockTime(), // Randomize the proposer address for each block. ProposerAddress: sample.ConsAddress().Bytes(), + DecidedLastCommit: cmtabcitypes.CommitInfo{ + Votes: []cmtabcitypes.VoteInfo{{}}, + }, }) require.NoError(t, err) @@ -963,6 +1001,11 @@ func (app *App) setupDefaultActorsState( app.NextBlock(t) } +// TODO_IN_THIS_COMMIT: godoc... +func (app *App) GetModuleManager() module.Manager { + return app.moduleManager +} + // fundAccount mints and sends amountUpokt tokens to the given recipientAddr. // // TODO_IMPROVE: Eliminate usage of and remove this function in favor of diff --git a/testutil/integration/options.go b/testutil/integration/options.go index 1695002cf..e273a14f5 100644 --- a/testutil/integration/options.go +++ b/testutil/integration/options.go @@ -5,6 +5,7 @@ import ( "github.com/cosmos/cosmos-sdk/codec" cosmostypes "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" + gogogrpc "github.com/cosmos/gogoproto/grpc" tlm "github.com/pokt-network/poktroll/x/tokenomics/token_logic_module" ) @@ -17,6 +18,8 @@ type IntegrationAppConfig struct { // InitChainer function. InitChainerModuleFns []InitChainerModuleFn TokenLogicModules []tlm.TokenLogicModule + + grpcServer gogogrpc.Server } // IntegrationAppOptionFn is a function that receives and has the opportunity to @@ -70,3 +73,10 @@ func WithTokenLogicModules(tokenLogicModules []tlm.TokenLogicModule) Integration config.TokenLogicModules = tokenLogicModules } } + +// TODO_IN_THIS_COMMIT: godoc... +func WithGRPCServer(grpcServer gogogrpc.Server) IntegrationAppOptionFn { + return func(config *IntegrationAppConfig) { + config.grpcServer = grpcServer + } +} diff --git a/testutil/testclient/localnet.go b/testutil/testclient/localnet.go index 354a1dd28..cc3d944ea 100644 --- a/testutil/testclient/localnet.go +++ b/testutil/testclient/localnet.go @@ -91,19 +91,18 @@ func NewLocalnetClientCtx(t gocuke.TestingT, flagSet *pflag.FlagSet) *client.Con // NewLocalnetFlagSet creates a set of predefined flags suitable for a localnet // testing environment. -// -// Parameters: -// - t: The testing.T instance used for the current test. -// -// Returns: -// - A flag set populated with flags tailored for localnet environments. func NewLocalnetFlagSet(t gocuke.TestingT) *pflag.FlagSet { t.Helper() + return NewFlagSet(t, CometLocalTCPURL) +} + +// NewFlagSet creates a set of predefined flags suitable for use with the given cometbft endpoint. +func NewFlagSet(t gocuke.TestingT, cometTCPURL string) *pflag.FlagSet { + t.Helper() + mockFlagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) - // TODO_IMPROVE: It would be nice if the value could be set correctly based - // on whether the test using it is running in tilt or not. - mockFlagSet.String(flags.FlagNode, CometLocalTCPURL, "use localnet poktrolld node") + mockFlagSet.String(flags.FlagNode, cometTCPURL, "use localnet poktrolld node") mockFlagSet.String(flags.FlagHome, "", "use localnet poktrolld node") mockFlagSet.String(flags.FlagKeyringBackend, "test", "use test keyring") mockFlagSet.String(flags.FlagChainID, app.Name, "use poktroll chain-id")