diff --git a/contribs/gnodev/cmd/gnodev/main.go b/contribs/gnodev/cmd/gnodev/main.go index 7a987965cda..2c694b608bb 100644 --- a/contribs/gnodev/cmd/gnodev/main.go +++ b/contribs/gnodev/cmd/gnodev/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "flag" "fmt" "log/slog" @@ -30,6 +31,8 @@ const ( AccountsLogName = "Accounts" ) +var ErrConflictingFileArgs = errors.New("cannot specify `balances-file` or `txs-file` along with `genesis-file`") + var ( DefaultDeployerName = integration.DefaultAccount_Name DefaultDeployerAddress = crypto.MustAddressFromString(integration.DefaultAccount_Address) @@ -47,8 +50,11 @@ type devCfg struct { home string root string premineAccounts varPremineAccounts - balancesFile string - txsFile string + + // Files + balancesFile string + genesisFile string + txsFile string // Web Configuration webListenerAddr string @@ -155,6 +161,13 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { "load the provided transactions file (refer to the documentation for format)", ) + fs.StringVar( + &c.genesisFile, + "genesis", + defaultDevOptions.genesisFile, + "load the given genesis file", + ) + fs.StringVar( &c.deployKey, "deploy-key", @@ -219,10 +232,22 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { ) } +func (c *devCfg) validateConfigFlags() error { + if (c.balancesFile != "" || c.txsFile != "") && c.genesisFile != "" { + return ErrConflictingFileArgs + } + + return nil +} + func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { ctx, cancel := context.WithCancelCause(context.Background()) defer cancel(nil) + if err := cfg.validateConfigFlags(); err != nil { + return fmt.Errorf("validate error: %w", err) + } + // Setup Raw Terminal rt, restore, err := setupRawTerm(cfg, io) if err != nil { @@ -262,7 +287,8 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { // Setup Dev Node // XXX: find a good way to export or display node logs nodeLogger := logger.WithGroup(NodeLogName) - devNode, err := setupDevNode(ctx, nodeLogger, cfg, emitterServer, balances, pkgpaths) + nodeCfg := setupDevNodeConfig(cfg, logger, emitterServer, balances, pkgpaths) + devNode, err := setupDevNode(ctx, cfg, nodeCfg) if err != nil { return err } @@ -337,11 +363,15 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { var helper string = `For more in-depth documentation, visit the GNO Tooling CLI documentation: https://docs.gno.land/gno-tooling/cli/gno-tooling-gnodev -A Accounts - Display known accounts and balances -H Help - Display this message -R Reload - Reload all packages to take change into account -Ctrl+R Reset - Reset application state -Ctrl+C Exit - Exit the application +P Previous TX - Go to the previous tx +N Next TX - Go to the next tx +E Export - Export the current state as genesis doc +A Accounts - Display known accounts and balances +H Help - Display this message +R Reload - Reload all packages to take change into account. +Ctrl+S Save State - Save the current state +Ctrl+R Reset - Reset application to it's initial/save state. +Ctrl+C Exit - Exit the application ` func runEventLoop( @@ -352,6 +382,20 @@ func runEventLoop( dnode *gnodev.Node, watch *watcher.PackageWatcher, ) error { + // XXX: move this in above, but we need to have a proper struct first + // XXX: make this configurable + var exported uint + path, err := os.MkdirTemp("", "gnodev-export") + if err != nil { + return fmt.Errorf("unable to create `export` directory: %w", err) + } + + defer func() { + if exported == 0 { + _ = os.RemoveAll(path) + } + }() + keyPressCh := listenForKeyPress(logger.WithGroup(KeyPressLogName), rt) for { var err error @@ -387,8 +431,10 @@ func runEventLoop( switch key.Upper() { case rawterm.KeyH: // Helper logger.Info("Gno Dev Helper", "helper", helper) + case rawterm.KeyA: // Accounts logAccounts(logger.WithGroup(AccountsLogName), bk, dnode) + case rawterm.KeyR: // Reload logger.WithGroup(NodeLogName).Info("reloading...") if err = dnode.ReloadAll(ctx); err != nil { @@ -403,8 +449,48 @@ func runEventLoop( Error("unable to reset node state", "err", err) } + case rawterm.KeyCtrlS: // Save + logger.WithGroup(NodeLogName).Info("saving state...") + if err := dnode.SaveCurrentState(ctx); err != nil { + logger.WithGroup(NodeLogName). + Error("unable to save node state", "err", err) + } + + case rawterm.KeyE: + logger.WithGroup(NodeLogName).Info("exporting state...") + doc, err := dnode.ExportStateAsGenesis(ctx) + if err != nil { + logger.WithGroup(NodeLogName). + Error("unable to export node state", "err", err) + continue + } + + docfile := filepath.Join(path, fmt.Sprintf("export_%d.jsonl", exported)) + if err := doc.SaveAs(docfile); err != nil { + logger.WithGroup(NodeLogName). + Error("unable to save genesis", "err", err) + } + exported++ + + logger.WithGroup(NodeLogName).Info("node state exported", "file", docfile) + + case rawterm.KeyN: // Next tx + logger.Info("moving forward...") + if err := dnode.MoveToNextTX(ctx); err != nil { + logger.WithGroup(NodeLogName). + Error("unable to move forward", "err", err) + } + + case rawterm.KeyP: // Next tx + logger.Info("moving backward...") + if err := dnode.MoveToPreviousTX(ctx); err != nil { + logger.WithGroup(NodeLogName). + Error("unable to move backward", "err", err) + } + case rawterm.KeyCtrlC: // Exit return nil + default: } diff --git a/contribs/gnodev/cmd/gnodev/setup_node.go b/contribs/gnodev/cmd/gnodev/setup_node.go index 55da1a596aa..578cf525751 100644 --- a/contribs/gnodev/cmd/gnodev/setup_node.go +++ b/contribs/gnodev/cmd/gnodev/setup_node.go @@ -10,43 +10,57 @@ import ( gnodev "github.com/gnolang/gno/contribs/gnodev/pkg/dev" "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" "github.com/gnolang/gno/gno.land/pkg/gnoland" - "github.com/gnolang/gno/tm2/pkg/std" + "github.com/gnolang/gno/tm2/pkg/bft/types" ) // setupDevNode initializes and returns a new DevNode. func setupDevNode( ctx context.Context, - logger *slog.Logger, - cfg *devCfg, - remitter emitter.Emitter, - balances gnoland.Balances, - pkgspath []gnodev.PackagePath, + devCfg *devCfg, + nodeConfig *gnodev.NodeConfig, ) (*gnodev.Node, error) { - // Load transactions. - txs, err := parseTxs(cfg.txsFile) - if err != nil { - return nil, fmt.Errorf("unable to load transactions: %w", err) + logger := nodeConfig.Logger + + if devCfg.txsFile != "" { // Load txs files + var err error + nodeConfig.InitialTxs, err = parseTxs(devCfg.txsFile) + if err != nil { + return nil, fmt.Errorf("unable to load transactions: %w", err) + } + } else if devCfg.genesisFile != "" { // Load genesis file + state, err := extractAppStateFromGenesisFile(devCfg.genesisFile) + if err != nil { + return nil, fmt.Errorf("unable to load genesis file %q: %w", devCfg.genesisFile, err) + } + + // Override balances and txs + nodeConfig.BalancesList = state.Balances + nodeConfig.InitialTxs = state.Txs + + logger.Info("genesis file loaded", "path", devCfg.genesisFile, "txs", len(nodeConfig.InitialTxs)) } - config := setupDevNodeConfig(cfg, balances, pkgspath, txs) - return gnodev.NewDevNode(ctx, logger, remitter, config) + return gnodev.NewDevNode(ctx, nodeConfig) } // setupDevNodeConfig creates and returns a new dev.NodeConfig. func setupDevNodeConfig( cfg *devCfg, + logger *slog.Logger, + emitter emitter.Emitter, balances gnoland.Balances, pkgspath []gnodev.PackagePath, - txs []std.Tx, ) *gnodev.NodeConfig { config := gnodev.DefaultNodeConfig(cfg.root) + + config.Logger = logger + config.Emitter = emitter config.BalancesList = balances.List() config.PackagesPathList = pkgspath config.TMConfig.RPC.ListenAddress = resolveUnixOrTCPAddr(cfg.nodeRPCListenerAddr) config.NoReplay = cfg.noReplay config.MaxGasPerBlock = cfg.maxGas config.ChainID = cfg.chainId - config.Txs = txs // other listeners config.TMConfig.P2P.ListenAddress = defaultDevOptions.nodeP2PListenerAddr @@ -55,6 +69,20 @@ func setupDevNodeConfig( return config } +func extractAppStateFromGenesisFile(path string) (*gnoland.GnoGenesisState, error) { + doc, err := types.GenesisDocFromFile(path) + if err != nil { + return nil, fmt.Errorf("unable to parse doc file: %w", err) + } + + state, ok := doc.AppState.(gnoland.GnoGenesisState) + if !ok { + return nil, fmt.Errorf("invalid `GnoGenesisState` app state") + } + + return &state, nil +} + func resolveUnixOrTCPAddr(in string) (out string) { var err error var addr net.Addr diff --git a/contribs/gnodev/go.mod b/contribs/gnodev/go.mod index 41c0153932e..77631a0190f 100644 --- a/contribs/gnodev/go.mod +++ b/contribs/gnodev/go.mod @@ -49,7 +49,7 @@ require ( github.com/peterbourgon/ff/v3 v3.4.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.4.3 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rs/cors v1.11.0 // indirect github.com/rs/xid v1.5.0 // indirect diff --git a/contribs/gnodev/go.sum b/contribs/gnodev/go.sum index 287b8fdb487..f4eb9423c21 100644 --- a/contribs/gnodev/go.sum +++ b/contribs/gnodev/go.sum @@ -146,8 +146,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= diff --git a/contribs/gnodev/internal/mock/server_emitter.go b/contribs/gnodev/internal/mock/server_emitter.go index d093d8855fe..eaf8d07210b 100644 --- a/contribs/gnodev/internal/mock/server_emitter.go +++ b/contribs/gnodev/internal/mock/server_emitter.go @@ -7,6 +7,12 @@ import ( "github.com/gnolang/gno/contribs/gnodev/pkg/events" ) +// Define empty event for NextEvent empty queue +var ( + eventNull = events.Custom("NULL") + EvtNull = eventNull.Type() +) + // ServerEmitter is an `emitter.Emitter` var _ emitter.Emitter = (*ServerEmitter)(nil) @@ -23,6 +29,8 @@ func (m *ServerEmitter) Emit(evt events.Event) { } func (m *ServerEmitter) NextEvent() (evt events.Event) { + evt = eventNull + m.muEvents.Lock() defer m.muEvents.Unlock() diff --git a/contribs/gnodev/pkg/dev/node.go b/contribs/gnodev/pkg/dev/node.go index 3f45cb53360..7f0c266bf48 100644 --- a/contribs/gnodev/pkg/dev/node.go +++ b/contribs/gnodev/pkg/dev/node.go @@ -29,21 +29,24 @@ import ( ) type NodeConfig struct { + Logger *slog.Logger DefaultDeployer crypto.Address BalancesList []gnoland.Balance PackagesPathList []PackagePath + Emitter emitter.Emitter + InitialTxs []std.Tx TMConfig *tmcfg.Config SkipFailingGenesisTxs bool NoReplay bool MaxGasPerBlock int64 ChainID string - Txs []std.Tx } func DefaultNodeConfig(rootdir string) *NodeConfig { tmc := gnoland.NewDefaultTMConfig(rootdir) tmc.Consensus.SkipTimeoutCommit = false // avoid time drifting, see issue #1507 tmc.Consensus.WALDisabled = true + tmc.Consensus.CreateEmptyBlocks = false defaultDeployer := crypto.MustAddressFromString(integration.DefaultAccount_Address) balances := []gnoland.Balance{ @@ -54,10 +57,11 @@ func DefaultNodeConfig(rootdir string) *NodeConfig { } return &NodeConfig{ + Logger: log.NewNoopLogger(), + Emitter: &emitter.NoopServer{}, DefaultDeployer: defaultDeployer, BalancesList: balances, ChainID: tmc.ChainID(), - PackagesPathList: []PackagePath{}, TMConfig: tmc, SkipFailingGenesisTxs: true, MaxGasPerBlock: 10_000_000_000, @@ -77,11 +81,15 @@ type Node struct { // keep track of number of loaded package to be able to skip them on restore loadedPackages int + + // state + initialState, state []std.Tx + currentStateIndex int } var DefaultFee = std.NewFee(50000, std.MustParseCoin("1000000ugnot")) -func NewDevNode(ctx context.Context, logger *slog.Logger, emitter emitter.Emitter, cfg *NodeConfig) (*Node, error) { +func NewDevNode(ctx context.Context, cfg *NodeConfig) (*Node, error) { mpkgs, err := NewPackagesMap(cfg.PackagesPathList) if err != nil { return nil, fmt.Errorf("unable map pkgs list: %w", err) @@ -91,25 +99,26 @@ func NewDevNode(ctx context.Context, logger *slog.Logger, emitter emitter.Emitte if err != nil { return nil, fmt.Errorf("unable to load genesis packages: %w", err) } - logger.Info("pkgs loaded", "path", cfg.PackagesPathList) + cfg.Logger.Info("pkgs loaded", "path", cfg.PackagesPathList) devnode := &Node{ - config: cfg, - client: client.NewLocal(), - emitter: emitter, - pkgs: mpkgs, - logger: logger, - loadedPackages: len(pkgsTxs), + config: cfg, + client: client.NewLocal(), + emitter: cfg.Emitter, + pkgs: mpkgs, + logger: cfg.Logger, + loadedPackages: len(pkgsTxs), + state: cfg.InitialTxs, + initialState: cfg.InitialTxs, + currentStateIndex: len(cfg.InitialTxs), } // generate genesis state genesis := gnoland.GnoGenesisState{ Balances: cfg.BalancesList, - Txs: pkgsTxs, + Txs: append(pkgsTxs, cfg.InitialTxs...), } - genesis.Txs = append(genesis.Txs, cfg.Txs...) - if err := devnode.rebuildNode(ctx, genesis); err != nil { return nil, fmt.Errorf("unable to initialize the node: %w", err) } @@ -253,11 +262,13 @@ func (n *Node) Reset(ctx context.Context) error { } // Generate a new genesis state based on the current packages - txs, err := n.pkgs.Load(DefaultFee) + pkgsTxs, err := n.pkgs.Load(DefaultFee) if err != nil { return fmt.Errorf("unable to load pkgs: %w", err) } + // Append initialTxs + txs := append(pkgsTxs, n.initialState...) genesis := gnoland.GnoGenesisState{ Balances: n.config.BalancesList, Txs: txs, @@ -269,6 +280,8 @@ func (n *Node) Reset(ctx context.Context) error { return fmt.Errorf("unable to initialize a new node: %w", err) } + n.loadedPackages = len(pkgsTxs) + n.currentStateIndex = len(n.initialState) n.emitter.Emit(&events.Reset{}) return nil } @@ -412,7 +425,42 @@ func (n *Node) rebuildNodeFromState(ctx context.Context) error { return nil } +func (n *Node) handleEventTX(evt tm2events.Event) { + switch data := evt.(type) { + case bft.EventTx: + go func() { + // Use a separate goroutine in order to avoid a deadlock situation. + // This is needed because this callback may get called during node rebuilding while + // lock is held. + n.muNode.Lock() + defer n.muNode.Unlock() + + heigh := n.BlockStore().Height() + n.currentStateIndex++ + n.state = nil // invalidate state + + n.logger.Info("node state", "index", n.currentStateIndex, "height", heigh) + }() + + resEvt := events.TxResult{ + Height: data.Result.Height, + Index: data.Result.Index, + // XXX: Update this to split error for stack + Response: data.Result.Response, + } + + if err := amino.Unmarshal(data.Result.Tx, &resEvt.Tx); err != nil { + n.logger.Error("unable to unwrap tx result", + "error", err) + } + + n.emitter.Emit(resEvt) + } +} + func (n *Node) rebuildNode(ctx context.Context, genesis gnoland.GnoGenesisState) (err error) { + noopLogger := log.NewNoopLogger() + // Stop the node if it's currently running. if err := n.stopIfRunning(); err != nil { return fmt.Errorf("unable to stop the node: %w", err) @@ -440,9 +488,16 @@ func (n *Node) rebuildNode(ctx context.Context, genesis gnoland.GnoGenesisState) // Execute node creation and handle any errors. defer recoverFromError() - node, nodeErr := buildNode(n.logger, n.emitter, nodeConfig) - if nodeErr != nil { // Then for any node error - return fmt.Errorf("unable to build the node: %w", nodeErr) + // XXX: Redirect the node log somewhere else + node, nodeErr := gnoland.NewInMemoryNode(noopLogger, nodeConfig) + if nodeErr != nil { + return fmt.Errorf("unable to create a new node: %w", err) + } + + node.EventSwitch().AddListener("dev-emitter", n.handleEventTX) + + if startErr := node.Start(); startErr != nil { + return fmt.Errorf("unable to start the node: %w", startErr) } // Wait for the node to be ready @@ -489,41 +544,6 @@ func (n *Node) genesisTxHandler(ctx sdk.Context, tx std.Tx, res sdk.Result) { return } -var noopLogger = log.NewNoopLogger() - -func buildNode(logger *slog.Logger, emitter emitter.Emitter, cfg *gnoland.InMemoryNodeConfig) (*node.Node, error) { - // XXX(TODO): Redirect the node log somewhere else - node, err := gnoland.NewInMemoryNode(noopLogger, cfg) - if err != nil { - return nil, fmt.Errorf("unable to create a new node: %w", err) - } - - node.EventSwitch().AddListener("dev-emitter", func(evt tm2events.Event) { - switch data := evt.(type) { - case bft.EventTx: - resEvt := events.TxResult{ - Height: data.Result.Height, - Index: data.Result.Index, - // XXX: Update this to split error for stack - Response: data.Result.Response, - } - - if err := amino.Unmarshal(data.Result.Tx, &resEvt.Tx); err != nil { - logger.Error("unable to unwarp tx result", - "error", err) - } - - emitter.Emit(resEvt) - } - }) - - if startErr := node.Start(); startErr != nil { - return nil, fmt.Errorf("unable to start the node: %w", startErr) - } - - return node, nil -} - func newNodeConfig(tmc *tmcfg.Config, chainid string, appstate gnoland.GnoGenesisState) *gnoland.InMemoryNodeConfig { // Create Mocked Identity pv := gnoland.NewMockedPrivValidator() diff --git a/contribs/gnodev/pkg/dev/node_state.go b/contribs/gnodev/pkg/dev/node_state.go new file mode 100644 index 00000000000..846c4857784 --- /dev/null +++ b/contribs/gnodev/pkg/dev/node_state.go @@ -0,0 +1,142 @@ +package dev + +import ( + "context" + "errors" + "fmt" + + "github.com/gnolang/gno/contribs/gnodev/pkg/events" + "github.com/gnolang/gno/gno.land/pkg/gnoland" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/std" +) + +var ErrEmptyState = errors.New("empty state") + +// Save the current state as initialState +func (n *Node) SaveCurrentState(ctx context.Context) error { + n.muNode.RLock() + defer n.muNode.RUnlock() + + // Get current blockstore state + state, err := n.getState(ctx) + if err != nil { + return fmt.Errorf("unable to save state: %s", err.Error()) + } + + n.initialState = state[:n.currentStateIndex] + return nil +} + +// Export the current state as list of txs +func (n *Node) ExportCurrentState(ctx context.Context) ([]std.Tx, error) { + n.muNode.RLock() + defer n.muNode.RUnlock() + + // Get current blockstore state + state, err := n.getState(ctx) + if err != nil { + return nil, fmt.Errorf("unable to save state: %s", err.Error()) + } + + return state[:n.currentStateIndex], nil +} + +func (n *Node) getState(ctx context.Context) ([]std.Tx, error) { + if n.state == nil { + var err error + n.state, err = n.getBlockStoreState(ctx) + if err != nil { + return nil, fmt.Errorf("unable to save state: %s", err.Error()) + } + } + + return n.state, nil +} + +// MoveBy adjusts the current state of the node by `x` transactions. +// `x` can be negative to move backward or positive to move forward, however, index boundaries are respected +// with a lower limit of 0 and upper limit equaling the total number of states. +// If a move is successful, node is reloaded. +func (n *Node) MoveBy(ctx context.Context, x int) error { + n.muNode.Lock() + defer n.muNode.Unlock() + + newIndex := n.currentStateIndex + x + state, err := n.getState(ctx) + if err != nil { + return fmt.Errorf("unable to get current state: %w", err) + } + + maxState := len(state) + switch { + case maxState == 0: // no state + return ErrEmptyState + case newIndex < 0: + newIndex = 0 + n.logger.Info("minimum state reached", "tx-index", fmt.Sprintf("%d/%d", newIndex, maxState)) + case newIndex > maxState: + newIndex = maxState + n.logger.Info("maximum state reached", "tx-index", fmt.Sprintf("%d/%d", newIndex, maxState)) + } + + if newIndex == n.currentStateIndex { + return nil + } + + // Load genesis packages + pkgsTxs, err := n.pkgs.Load(DefaultFee) + if err != nil { + return fmt.Errorf("unable to load pkgs: %w", err) + } + + newState := n.state[:newIndex] + + // Create genesis with loaded pkgs + previous state + genesis := gnoland.GnoGenesisState{ + Balances: n.config.BalancesList, + Txs: append(pkgsTxs, newState...), + } + + // Reset the node with the new genesis state. + if err = n.rebuildNode(ctx, genesis); err != nil { + return fmt.Errorf("uanble to rebuild node: %w", err) + } + + n.logger.Info("moving to", "tx-index", fmt.Sprintf("%d/%d", newIndex, maxState)) + + // Update node infos + n.currentStateIndex = newIndex + n.emitter.Emit(&events.Reload{}) + + return nil +} + +func (n *Node) MoveToPreviousTX(ctx context.Context) error { + return n.MoveBy(ctx, -1) +} + +func (n *Node) MoveToNextTX(ctx context.Context) error { + return n.MoveBy(ctx, 1) +} + +// Export the current state as genesis doc +func (n *Node) ExportStateAsGenesis(ctx context.Context) (*bft.GenesisDoc, error) { + n.muNode.RLock() + defer n.muNode.RUnlock() + + // Get current blockstore state + state, err := n.getState(ctx) + if err != nil { + return nil, fmt.Errorf("unable to save state: %s", err.Error()) + } + + // Get current blockstore state + doc := *n.Node.GenesisDoc() // copy doc + doc.AppState = gnoland.GnoGenesisState{ + Balances: n.config.BalancesList, + Txs: state, + } + + return &doc, nil +} diff --git a/contribs/gnodev/pkg/dev/node_state_test.go b/contribs/gnodev/pkg/dev/node_state_test.go new file mode 100644 index 00000000000..17f96367512 --- /dev/null +++ b/contribs/gnodev/pkg/dev/node_state_test.go @@ -0,0 +1,198 @@ +package dev + +import ( + "context" + "strconv" + "testing" + "time" + + emitter "github.com/gnolang/gno/contribs/gnodev/internal/mock" + "github.com/gnolang/gno/contribs/gnodev/pkg/events" + "github.com/gnolang/gno/gno.land/pkg/gnoclient" + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testCounterRealm = "gno.land/r/dev/counter" + +func TestNodeMovePreviousTX(t *testing.T) { + const callInc = 5 + + node, emitter := testingCounterRealm(t, callInc) + + t.Run("Prev TX", func(t *testing.T) { + ctx := testingContext(t) + err := node.MoveToPreviousTX(ctx) + require.NoError(t, err) + assert.Equal(t, events.EvtReload, emitter.NextEvent().Type()) + + // Check for correct render update + render, err := testingRenderRealm(t, node, testCounterRealm) + require.NoError(t, err) + require.Equal(t, render, "4") + }) + + t.Run("Next TX", func(t *testing.T) { + ctx := testingContext(t) + err := node.MoveToNextTX(ctx) + require.NoError(t, err) + assert.Equal(t, events.EvtReload, emitter.NextEvent().Type()) + + // Check for correct render update + render, err := testingRenderRealm(t, node, testCounterRealm) + require.NoError(t, err) + require.Equal(t, render, "5") + }) + + t.Run("Multi Move TX", func(t *testing.T) { + ctx := testingContext(t) + moves := []struct { + Move int + ExpectedResult string + }{ + {-2, "3"}, + {2, "5"}, + {-5, "0"}, + {5, "5"}, + {-100, "0"}, + {100, "5"}, + {0, "5"}, + } + + t.Logf("initial state %d", callInc) + for _, tc := range moves { + t.Logf("moving from `%d`", tc.Move) + err := node.MoveBy(ctx, tc.Move) + require.NoError(t, err) + if tc.Move != 0 { + assert.Equal(t, events.EvtReload, emitter.NextEvent().Type()) + } + + // Check for correct render update + render, err := testingRenderRealm(t, node, testCounterRealm) + require.NoError(t, err) + require.Equal(t, render, tc.ExpectedResult) + } + }) +} + +func TestSaveCurrentState(t *testing.T) { + ctx := testingContext(t) + + node, emitter := testingCounterRealm(t, 2) + + // Save current state + err := node.SaveCurrentState(ctx) + require.NoError(t, err) + + // Send a new tx + msg := gnoclient.MsgCall{ + PkgPath: testCounterRealm, + FuncName: "Inc", + Args: []string{"10"}, + } + + res, err := testingCallRealm(t, node, msg) + require.NoError(t, err) + require.NoError(t, res.CheckTx.Error) + require.NoError(t, res.DeliverTx.Error) + assert.Equal(t, events.EvtTxResult, emitter.NextEvent().Type()) + + // Test render + render, err := testingRenderRealm(t, node, testCounterRealm) + require.NoError(t, err) + require.Equal(t, render, "12") // 2 + 10 + + // Reset state + err = node.Reset(ctx) + require.NoError(t, err) + assert.Equal(t, events.EvtReset, emitter.NextEvent().Type()) + + render, err = testingRenderRealm(t, node, testCounterRealm) + require.NoError(t, err) + require.Equal(t, render, "2") // Back to the original state +} + +func TestExportState(t *testing.T) { + node, _ := testingCounterRealm(t, 3) + + t.Run("export state", func(t *testing.T) { + ctx := testingContext(t) + state, err := node.ExportCurrentState(ctx) + require.NoError(t, err) + assert.Equal(t, 3, len(state)) + }) + + t.Run("export genesis doc", func(t *testing.T) { + ctx := testingContext(t) + doc, err := node.ExportStateAsGenesis(ctx) + require.NoError(t, err) + require.NotNil(t, doc.AppState) + + state, ok := doc.AppState.(gnoland.GnoGenesisState) + require.True(t, ok) + assert.Equal(t, 3, len(state.Txs)) + }) +} + +func testingCounterRealm(t *testing.T, inc int) (*Node, *emitter.ServerEmitter) { + t.Helper() + + const ( + // foo package + counterGnoMod = "module gno.land/r/dev/counter\n" + counterFile = `package counter +import "strconv" + +var value int = 0 +func Inc(v int) { value += v } // method to increment value +func Render(_ string) string { return strconv.Itoa(value) } +` + ) + + // Generate package counter + counterPkg := generateTestingPackage(t, + "gno.mod", counterGnoMod, + "foo.gno", counterFile) + + // Call NewDevNode with no package should work + node, emitter := newTestingDevNode(t, counterPkg) + assert.Len(t, node.ListPkgs(), 1) + + // Test rendering + render, err := testingRenderRealm(t, node, testCounterRealm) + require.NoError(t, err) + require.Equal(t, render, "0") + + // Increment the counter 10 times + for i := 0; i < inc; i++ { + t.Logf("call %d", i) + // Craft `Inc` msg + msg := gnoclient.MsgCall{ + PkgPath: testCounterRealm, + FuncName: "Inc", + Args: []string{"1"}, + } + + res, err := testingCallRealm(t, node, msg) + require.NoError(t, err) + require.NoError(t, res.CheckTx.Error) + require.NoError(t, res.DeliverTx.Error) + assert.Equal(t, events.EvtTxResult, emitter.NextEvent().Type()) + } + + render, err = testingRenderRealm(t, node, testCounterRealm) + require.NoError(t, err) + require.Equal(t, render, strconv.Itoa(inc)) + + return node, emitter +} + +func testingContext(t *testing.T) context.Context { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*7) + t.Cleanup(cancel) + return ctx +} diff --git a/contribs/gnodev/pkg/dev/node_test.go b/contribs/gnodev/pkg/dev/node_test.go index d4868a5766f..48204b4ce8d 100644 --- a/contribs/gnodev/pkg/dev/node_test.go +++ b/contribs/gnodev/pkg/dev/node_test.go @@ -8,7 +8,6 @@ import ( mock "github.com/gnolang/gno/contribs/gnodev/internal/mock" - "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" "github.com/gnolang/gno/contribs/gnodev/pkg/events" "github.com/gnolang/gno/gno.land/pkg/gnoclient" "github.com/gnolang/gno/gno.land/pkg/integration" @@ -34,7 +33,8 @@ func TestNewNode_NoPackages(t *testing.T) { // Call NewDevNode with no package should work cfg := DefaultNodeConfig(gnoenv.RootDir()) - node, err := NewDevNode(ctx, logger, &emitter.NoopServer{}, cfg) + cfg.Logger = logger + node, err := NewDevNode(ctx, cfg) require.NoError(t, err) assert.Len(t, node.ListPkgs(), 0) @@ -62,7 +62,8 @@ func Render(_ string) string { return "foo" } // Call NewDevNode with no package should work cfg := DefaultNodeConfig(gnoenv.RootDir()) cfg.PackagesPathList = []PackagePath{pkgpath} - node, err := NewDevNode(ctx, logger, &emitter.NoopServer{}, cfg) + cfg.Logger = logger + node, err := NewDevNode(ctx, cfg) require.NoError(t, err) assert.Len(t, node.ListPkgs(), 1) @@ -154,14 +155,14 @@ func Render(_ string) string { return "bar" } require.NoError(t, err) // Check reload event - assert.Equal(t, emitter.NextEvent().Type(), events.EvtReload) + assert.Equal(t, events.EvtReload, emitter.NextEvent().Type()) // After a reload, render should succeed render, err = testingRenderRealm(t, node, "gno.land/r/dev/foobar") require.NoError(t, err) require.Equal(t, render, "bar") - assert.Nil(t, emitter.NextEvent()) + assert.Equal(t, mock.EvtNull, emitter.NextEvent().Type()) } func TestNodeReset(t *testing.T) { @@ -215,7 +216,7 @@ func Render(_ string) string { return str } require.NoError(t, err) require.Equal(t, render, "foo") - assert.Nil(t, emitter.NextEvent()) + assert.Equal(t, mock.EvtNull, emitter.NextEvent().Type()) } func testingRenderRealm(t *testing.T, node *Node, rlmpath string) (string, error) { @@ -286,7 +287,9 @@ func newTestingDevNode(t *testing.T, pkgslist ...PackagePath) (*Node, *mock.Serv // Call NewDevNode with no package should work cfg := DefaultNodeConfig(gnoenv.RootDir()) cfg.PackagesPathList = pkgslist - node, err := NewDevNode(ctx, logger, emitter, cfg) + cfg.Emitter = emitter + cfg.Logger = logger + node, err := NewDevNode(ctx, cfg) require.NoError(t, err) assert.Len(t, node.ListPkgs(), len(pkgslist)) diff --git a/contribs/gnodev/pkg/rawterm/keypress.go b/contribs/gnodev/pkg/rawterm/keypress.go index 48f8367a65b..45c64c999dd 100644 --- a/contribs/gnodev/pkg/rawterm/keypress.go +++ b/contribs/gnodev/pkg/rawterm/keypress.go @@ -16,10 +16,15 @@ const ( KeyCtrlL KeyPress = '\x0c' // Ctrl+L KeyCtrlO KeyPress = '\x0f' // Ctrl+O KeyCtrlR KeyPress = '\x12' // Ctrl+R + KeyCtrlS KeyPress = '\x13' // Ctrl+S KeyCtrlT KeyPress = '\x14' // Ctrl+T KeyA KeyPress = 'A' + KeyE KeyPress = 'E' KeyH KeyPress = 'H' + KeyI KeyPress = 'I' + KeyN KeyPress = 'N' + KeyP KeyPress = 'P' KeyR KeyPress = 'R' ) @@ -43,6 +48,8 @@ func (k KeyPress) String() string { return "Ctrl+O" case KeyCtrlR: return "Ctrl+R" + case KeyCtrlS: + return "Ctrl+S" case KeyCtrlT: return "Ctrl+T" default: diff --git a/docs/gno-tooling/cli/gnodev.md b/docs/gno-tooling/cli/gnodev.md index c641a986a4e..4a1880822fc 100644 --- a/docs/gno-tooling/cli/gnodev.md +++ b/docs/gno-tooling/cli/gnodev.md @@ -21,6 +21,8 @@ local instance of `gnoweb`, allowing you to see the rendering of your Gno code i file changes, reloading and automatically restarting the node as needed. - **State Maintenance**: Gnodev replays all transactions in between reloads, ensuring the previous node state is preserved. +- **Transaction Manipulation**: Gnodev adds the capability to cancel and redo transactions interactively. +- **State Export:** Export the current state at any time in a genesis doc format. ## Installation @@ -115,6 +117,11 @@ While `gnodev` is running, the following shortcuts are available: - To see help, press `H`. - To display accounts balances, press `A`. - To reload manually, press `R`. +- To cancel the last action, press `P`. +- To redo the last cancelled action, press `N`. +- To save the current state, press `Ctrl+S`. +- To restore the saved state, press `Ctrl+R`. +- To export the current state to a genesis file, press `E`. - To reset the state of the node, press `CMD+R`. - To stop `gnodev`, press `CMD+C`. @@ -137,3 +144,5 @@ While `gnodev` is running, the following shortcuts are available: | --verbose | enable verbose output for development | | --web-listener | web server listening address | | --web-help-remote | web server help page's remote addr - defaults to | +| --genesis-file | Load and extract transactions from a genesis file | +