diff --git a/contribs/gnodev/Makefile b/contribs/gnodev/Makefile index 23fb22a372d..b98ce0fb44b 100644 --- a/contribs/gnodev/Makefile +++ b/contribs/gnodev/Makefile @@ -1,9 +1,14 @@ GNOROOT_DIR ?= $(abspath $(lastword $(MAKEFILE_LIST))/../../../) +GOBUILD_FLAGS ?= -ldflags "-X github.com/gnolang/gno/gnovm/pkg/gnoenv._GNOROOT=$(GNOROOT_DIR)" -GOBUILD_FLAGS := -ldflags "-X github.com/gnolang/gno/gnovm/pkg/gnoenv._GNOROOT=$(GNOROOT_DIR)" +rundep := go run -modfile ../../misc/devdeps/go.mod +golangci_lint := $(rundep) github.com/golangci/golangci-lint/cmd/golangci-lint install: - go install $(GOBUILD_FLAGS) . + go install $(GOBUILD_FLAGS) ./cmd/gnodev build: - go build $(GOBUILD_FLAGS) -o build/gnodev ./cmd/gno + go build $(GOBUILD_FLAGS) -o build/gnodev ./cmd/gnodev + +lint: + $(golangci_lint) --config ../../.github/golangci.yml run ./... diff --git a/contribs/gnodev/cmd/gnodev/accounts.go b/contribs/gnodev/cmd/gnodev/accounts.go new file mode 100644 index 00000000000..b263cc44f70 --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/accounts.go @@ -0,0 +1,148 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + "strings" + "text/tabwriter" + + "github.com/gnolang/gno/contribs/gnodev/pkg/address" + "github.com/gnolang/gno/contribs/gnodev/pkg/dev" + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/gnolang/gno/tm2/pkg/std" +) + +type varPremineAccounts map[string]std.Coins // name or bech32 to coins. + +func (va *varPremineAccounts) Set(value string) error { + if *va == nil { + *va = map[string]std.Coins{} + } + accounts := *va + + user, amount, found := strings.Cut(value, "=") + accounts[user] = nil + if !found { + return nil + } + + coins, err := std.ParseCoins(amount) + if err != nil { + return fmt.Errorf("unable to parse coins from %q: %w", user, err) + } + + // Add the parsed amount to the user. + accounts[user] = coins + return nil +} + +func (va varPremineAccounts) String() string { + accs := make([]string, 0, len(va)) + for user, balance := range va { + accs = append(accs, fmt.Sprintf("%s(%s)", user, balance.String())) + } + + return strings.Join(accs, ",") +} + +func generateBalances(bk *address.Book, cfg *devCfg) (gnoland.Balances, error) { + bls := gnoland.NewBalances() + premineBalance := std.Coins{std.NewCoin("ugnot", 10e12)} + + entries := bk.List() + + // Automatically set every key from keybase to unlimited fund. + for _, entry := range entries { + address := entry.Address + + // Check if a predefined amount has been set for this key. + + // Check for address + if preDefinedFound, ok := cfg.premineAccounts[address.String()]; ok && preDefinedFound != nil { + bls[address] = gnoland.Balance{Amount: preDefinedFound, Address: address} + continue + } + + // Check for name + found := premineBalance + for _, name := range entry.Names { + if preDefinedFound, ok := cfg.premineAccounts[name]; ok && preDefinedFound != nil { + found = preDefinedFound + break + } + } + + bls[address] = gnoland.Balance{Amount: found, Address: address} + } + + if cfg.balancesFile == "" { + return bls, nil + } + + // Load balance file + + file, err := os.Open(cfg.balancesFile) + if err != nil { + return nil, fmt.Errorf("unable to open balance file %q: %w", cfg.balancesFile, err) + } + + blsFile, err := gnoland.GetBalancesFromSheet(file) + if err != nil { + return nil, fmt.Errorf("unable to read balances file %q: %w", cfg.balancesFile, err) + } + + // Add balance address to AddressBook + for addr := range blsFile { + bk.Add(addr, "") + } + + // Left merge keybase balance into loaded file balance. + // TL;DR: balance file override every balance at the end + blsFile.LeftMerge(bls) + return blsFile, nil +} + +func logAccounts(logger *slog.Logger, book *address.Book, _ *dev.Node) error { + var tab strings.Builder + tabw := tabwriter.NewWriter(&tab, 0, 0, 2, ' ', tabwriter.TabIndent) + + entries := book.List() + + fmt.Fprintln(tabw, "KeyName\tAddress\tBalance") // Table header. + + for _, entry := range entries { + address := entry.Address.String() + qres, err := client.NewLocal().ABCIQuery("auth/accounts/"+address, []byte{}) + if err != nil { + return fmt.Errorf("unable to query account %q: %w", address, err) + } + + var qret struct{ BaseAccount std.BaseAccount } + if err = amino.UnmarshalJSON(qres.Response.Data, &qret); err != nil { + return fmt.Errorf("unable to unmarshal query response: %w", err) + } + + if len(entry.Names) == 0 { + // Insert row with name, address, and balance amount. + fmt.Fprintf(tabw, "%s\t%s\t%s\n", "_", address, qret.BaseAccount.GetCoins().String()) + continue + } + + for _, name := range entry.Names { + // Insert row with name, address, and balance amount. + fmt.Fprintf(tabw, "%s\t%s\t%s\n", name, + address, + qret.BaseAccount.GetCoins().String()) + } + } + + // Flush table. + tabw.Flush() + + headline := fmt.Sprintf("(%d) known keys", len(entries)) + logger.Info(headline, "table", tab.String()) + return nil +} diff --git a/contribs/gnodev/cmd/gnodev/logger.go b/contribs/gnodev/cmd/gnodev/logger.go new file mode 100644 index 00000000000..9e69654f478 --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/logger.go @@ -0,0 +1,35 @@ +package main + +import ( + "io" + "log/slog" + + "github.com/charmbracelet/lipgloss" + "github.com/gnolang/gno/contribs/gnodev/pkg/logger" + gnolog "github.com/gnolang/gno/gno.land/pkg/log" + "github.com/muesli/termenv" +) + +func setuplogger(cfg *devCfg, out io.Writer) *slog.Logger { + level := slog.LevelInfo + if cfg.verbose { + level = slog.LevelDebug + } + + if cfg.serverMode { + zaplogger := logger.NewZapLogger(out, level) + return gnolog.ZapLoggerToSlog(zaplogger) + } + + // Detect term color profile + colorProfile := termenv.DefaultOutput().Profile + clogger := logger.NewColumnLogger(out, level, colorProfile) + + // Register well known group color with system colors + clogger.RegisterGroupColor(NodeLogName, lipgloss.Color("3")) + clogger.RegisterGroupColor(WebLogName, lipgloss.Color("4")) + clogger.RegisterGroupColor(KeyPressLogName, lipgloss.Color("5")) + clogger.RegisterGroupColor(EventServerLogName, lipgloss.Color("6")) + + return slog.New(clogger) +} diff --git a/contribs/gnodev/main.go b/contribs/gnodev/cmd/gnodev/main.go similarity index 53% rename from contribs/gnodev/main.go rename to contribs/gnodev/cmd/gnodev/main.go index 6e6d12fcbdc..652b4a862a3 100644 --- a/contribs/gnodev/main.go +++ b/contribs/gnodev/cmd/gnodev/main.go @@ -4,30 +4,22 @@ import ( "context" "flag" "fmt" - "io" "log/slog" - "net" "net/http" "os" "path/filepath" - "strings" "time" - "github.com/charmbracelet/lipgloss" - "github.com/fsnotify/fsnotify" - "github.com/gnolang/gno/contribs/gnodev/pkg/dev" + "github.com/gnolang/gno/contribs/gnodev/pkg/address" gnodev "github.com/gnolang/gno/contribs/gnodev/pkg/dev" "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" - "github.com/gnolang/gno/contribs/gnodev/pkg/logger" "github.com/gnolang/gno/contribs/gnodev/pkg/rawterm" "github.com/gnolang/gno/contribs/gnodev/pkg/watcher" - "github.com/gnolang/gno/gno.land/pkg/gnoweb" - zaplog "github.com/gnolang/gno/gno.land/pkg/log" + "github.com/gnolang/gno/gno.land/pkg/integration" "github.com/gnolang/gno/gnovm/pkg/gnoenv" - "github.com/gnolang/gno/gnovm/pkg/gnomod" "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/crypto" osm "github.com/gnolang/gno/tm2/pkg/os" - "github.com/muesli/termenv" ) const ( @@ -35,17 +27,32 @@ const ( WebLogName = "GnoWeb" KeyPressLogName = "KeyPress" EventServerLogName = "Event" + AccountsLogName = "Accounts" +) + +var ( + DefaultDeployerName = integration.DefaultAccount_Name + DefaultDeployerAddress = crypto.MustAddressFromString(integration.DefaultAccount_Address) + DefaultDeployerSeed = integration.DefaultAccount_Seed ) type devCfg struct { + // Listeners webListenerAddr string nodeRPCListenerAddr string nodeP2PListenerAddr string nodeProxyAppListenerAddr string + // Users default + deployKey string + home string + root string + premineAccounts varPremineAccounts + balancesFile string + + // Node Configuration minimal bool verbose bool - hotreload bool noWatch bool noReplay bool maxGas int64 @@ -58,6 +65,9 @@ var defaultDevOptions = &devCfg{ maxGas: 10_000_000_000, webListenerAddr: "127.0.0.1:8888", nodeRPCListenerAddr: "127.0.0.1:36657", + deployKey: DefaultDeployerAddress.String(), + home: gnoenv.HomeDir(), + root: gnoenv.RootDir(), // As we have no reason to configure this yet, set this to random port // to avoid potential conflict with other app @@ -87,6 +97,20 @@ additional specified paths.`, } func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.home, + "home", + defaultDevOptions.home, + "user's local directory for keys", + ) + + fs.StringVar( + &c.root, + "root", + defaultDevOptions.root, + "gno root directory", + ) + fs.StringVar( &c.webListenerAddr, "web-listener", @@ -98,14 +122,34 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { &c.nodeRPCListenerAddr, "node-rpc-listener", defaultDevOptions.nodeRPCListenerAddr, - "gnoland rpc node listening address", + "listening address for GnoLand RPC node", + ) + + fs.Var( + &c.premineAccounts, + "add-account", + "add (or set) a premine account in the form `[=]`, can be used multiple time", + ) + + fs.StringVar( + &c.balancesFile, + "balance-file", + defaultDevOptions.balancesFile, + "load the provided balance file (refer to the documentation for format)", + ) + + fs.StringVar( + &c.deployKey, + "deploy-key", + defaultDevOptions.deployKey, + "default key name or Bech32 address for deploying packages", ) fs.BoolVar( &c.minimal, "minimal", defaultDevOptions.minimal, - "do not load packages from examples directory", + "do not load packages from the examples directory", ) fs.BoolVar( @@ -119,7 +163,7 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { &c.verbose, "verbose", defaultDevOptions.verbose, - "verbose output when deving", + "enable verbose output for development", ) fs.StringVar( @@ -133,21 +177,21 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { &c.noWatch, "no-watch", defaultDevOptions.noWatch, - "do not watch for files change", + "do not watch for file changes", ) fs.BoolVar( &c.noReplay, "no-replay", defaultDevOptions.noReplay, - "do not replay previous transactions on reload", + "do not replay previous transactions upon reload", ) fs.Int64Var( &c.maxGas, "max-gas", defaultDevOptions.maxGas, - "set the maximum gas by block", + "set the maximum gas per block", ) } @@ -155,20 +199,6 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { ctx, cancel := context.WithCancelCause(context.Background()) defer cancel(nil) - // Guess root dir - gnoroot := gnoenv.RootDir() - - // Check and Parse packages - pkgpaths, err := parseArgsPackages(args) - if err != nil { - return fmt.Errorf("unable to parse package paths: %w", err) - } - - if !cfg.minimal { - examplesDir := filepath.Join(gnoroot, "examples") - pkgpaths = append(pkgpaths, examplesDir) - } - // Setup Raw Terminal rt, restore, err := setupRawTerm(cfg, io) if err != nil { @@ -186,26 +216,42 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { loggerEvents := logger.WithGroup(EventServerLogName) emitterServer := emitter.NewServer(loggerEvents) + // load keybase + book, err := setupAddressBook(logger.WithGroup(AccountsLogName), cfg) + if err != nil { + return fmt.Errorf("unable to load keybase: %w", err) + } + + // Check and Parse packages + pkgpaths, err := resolvePackagesPathFromArgs(cfg, book, args) + if err != nil { + return fmt.Errorf("unable to parse package paths: %w", err) + } + + // generate balances + balances, err := generateBalances(book, cfg) + if err != nil { + return fmt.Errorf("unable to generate balances: %w", err) + } + logger.Debug("balances loaded", "list", balances.List()) + // Setup Dev Node // XXX: find a good way to export or display node logs - devNode, err := setupDevNode(ctx, logger, cfg, emitterServer, pkgpaths) + nodeLogger := logger.WithGroup(NodeLogName) + devNode, err := setupDevNode(ctx, nodeLogger, cfg, emitterServer, balances, pkgpaths) if err != nil { return err } defer devNode.Close() - logger.WithGroup(NodeLogName). - Info("node started", - "lisn", devNode.GetRemoteAddress(), - "addr", gnodev.DefaultCreator.String(), - "chainID", cfg.chainId, - ) + nodeLogger.Info("node started", "lisn", devNode.GetRemoteAddress(), "chainID", cfg.chainId) // Create server mux := http.NewServeMux() server := http.Server{ - Handler: mux, - Addr: cfg.webListenerAddr, + Handler: mux, + Addr: cfg.webListenerAddr, + ReadHeaderTimeout: time.Minute, } defer server.Close() @@ -239,14 +285,17 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { // Add node pkgs to watcher watcher.AddPackages(devNode.ListPkgs()...) - logger.WithGroup("--- READY").Info("for commands and help, press `h`") + if !cfg.serverMode { + logger.WithGroup("--- READY").Info("for commands and help, press `h`") + } // Run the main event loop - return runEventLoop(ctx, logger, rt, devNode, watcher) + return runEventLoop(ctx, logger, book, rt, devNode, watcher) } var helper string = ` -H Help - display this message +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 @@ -255,11 +304,11 @@ Ctrl+C Exit - Exit the application func runEventLoop( ctx context.Context, logger *slog.Logger, + bk *address.Book, rt *rawterm.RawTerm, - dnode *dev.Node, + dnode *gnodev.Node, watch *watcher.PackageWatcher, ) error { - keyPressCh := listenForKeyPress(logger.WithGroup(KeyPressLogName), rt) for { var err error @@ -293,27 +342,25 @@ func runEventLoop( ) switch key.Upper() { - case rawterm.KeyH: + case rawterm.KeyH: // Helper logger.Info("Gno Dev Helper", "helper", helper) - case rawterm.KeyR: + 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 { logger.WithGroup(NodeLogName). Error("unable to reload node", "err", err) - } - case rawterm.KeyCtrlR: + case rawterm.KeyCtrlR: // Reset logger.WithGroup(NodeLogName).Info("reseting node state...") if err = dnode.Reset(ctx); err != nil { logger.WithGroup(NodeLogName). Error("unable to reset node state", "err", err) } - case rawterm.KeyCtrlC: - return nil - case rawterm.KeyCtrlE: - panic("NOOOO") + case rawterm.KeyCtrlC: // Exit return nil default: } @@ -324,126 +371,6 @@ func runEventLoop( } } -func runPkgsWatcher(ctx context.Context, cfg *devCfg, pkgs []gnomod.Pkg, changedPathsCh chan<- []string) error { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return fmt.Errorf("unable to watch files: %w", err) - } - - if cfg.noWatch { - // Noop watcher, wait until context has been cancel - <-ctx.Done() - return ctx.Err() - } - - for _, pkg := range pkgs { - if err := watcher.Add(pkg.Dir); err != nil { - return fmt.Errorf("unable to watch %q: %w", pkg.Dir, err) - } - } - - const timeout = time.Millisecond * 500 - - var debounceTimer <-chan time.Time - pathList := []string{} - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case watchErr := <-watcher.Errors: - return fmt.Errorf("watch error: %w", watchErr) - case <-debounceTimer: - changedPathsCh <- pathList - // Reset pathList and debounceTimer - pathList = []string{} - debounceTimer = nil - case evt := <-watcher.Events: - if evt.Op != fsnotify.Write { - continue - } - - pathList = append(pathList, evt.Name) - debounceTimer = time.After(timeout) - } - } -} - -var noopRestore = func() error { return nil } - -func setupRawTerm(cfg *devCfg, io commands.IO) (*rawterm.RawTerm, func() error, error) { - rt := rawterm.NewRawTerm() - restore := noopRestore - if !cfg.serverMode { - var err error - restore, err = rt.Init() - if err != nil { - return nil, nil, err - } - } - - // correctly format output for terminal - io.SetOut(commands.WriteNopCloser(rt)) - return rt, restore, nil -} - -// setupDevNode initializes and returns a new DevNode. -func setupDevNode( - ctx context.Context, - logger *slog.Logger, - cfg *devCfg, - remitter emitter.Emitter, - pkgspath []string, -) (*gnodev.Node, error) { - nodeLogger := logger.WithGroup(NodeLogName) - - gnoroot := gnoenv.RootDir() - - // configure gnoland node - config := gnodev.DefaultNodeConfig(gnoroot) - config.PackagesPathList = pkgspath - config.TMConfig.RPC.ListenAddress = resolveUnixOrTCPAddr(cfg.nodeRPCListenerAddr) - config.NoReplay = cfg.noReplay - config.MaxGasPerBlock = cfg.maxGas - config.ChainID = cfg.chainId - - // other listeners - config.TMConfig.P2P.ListenAddress = defaultDevOptions.nodeP2PListenerAddr - config.TMConfig.ProxyApp = defaultDevOptions.nodeProxyAppListenerAddr - - return gnodev.NewDevNode(ctx, nodeLogger, remitter, config) -} - -// setupGnowebServer initializes and starts the Gnoweb server. -func setupGnoWebServer(logger *slog.Logger, cfg *devCfg, dnode *gnodev.Node) http.Handler { - webConfig := gnoweb.NewDefaultConfig() - webConfig.RemoteAddr = dnode.GetRemoteAddress() - webConfig.HelpRemote = dnode.GetRemoteAddress() - webConfig.HelpChainID = cfg.chainId - - app := gnoweb.MakeApp(logger, webConfig) - return app.Router -} - -func parseArgsPackages(args []string) (paths []string, err error) { - paths = make([]string, len(args)) - for i, arg := range args { - abspath, err := filepath.Abs(arg) - if err != nil { - return nil, fmt.Errorf("invalid path %q: %w", arg, err) - } - - ppath, err := gnomod.FindRootDir(abspath) - if err != nil { - return nil, fmt.Errorf("unable to find root dir of %q: %w", abspath, err) - } - - paths[i] = ppath - } - - return paths, nil -} - func listenForKeyPress(logger *slog.Logger, rt *rawterm.RawTerm) <-chan rawterm.KeyPress { cc := make(chan rawterm.KeyPress, 1) go func() { @@ -460,49 +387,40 @@ func listenForKeyPress(logger *slog.Logger, rt *rawterm.RawTerm) <-chan rawterm. return cc } -func resolveUnixOrTCPAddr(in string) (out string) { - var err error - var addr net.Addr +func resolvePackagesPathFromArgs(cfg *devCfg, bk *address.Book, args []string) ([]gnodev.PackagePath, error) { + paths := make([]gnodev.PackagePath, 0, len(args)) - if strings.HasPrefix(in, "unix://") { - in = strings.TrimPrefix(in, "unix://") - if addr, err := net.ResolveUnixAddr("unix", in); err == nil { - return fmt.Sprintf("%s://%s", addr.Network(), addr.String()) - } - - err = fmt.Errorf("unable to resolve unix address `unix://%s`: %w", in, err) - } else { // don't bother to checking prefix - in = strings.TrimPrefix(in, "tcp://") - if addr, err = net.ResolveTCPAddr("tcp", in); err == nil { - return fmt.Sprintf("%s://%s", addr.Network(), addr.String()) - } + if cfg.deployKey == "" { + return nil, fmt.Errorf("default deploy key cannot be empty") + } - err = fmt.Errorf("unable to resolve tcp address `tcp://%s`: %w", in, err) + defaultKey, _, ok := bk.GetFromNameOrAddress(cfg.deployKey) + if !ok { + return nil, fmt.Errorf("unable to get deploy key %q", cfg.deployKey) } - panic(err) -} + for _, arg := range args { + path, err := gnodev.ResolvePackagePathQuery(bk, arg) + if err != nil { + return nil, fmt.Errorf("invalid package path/query %q: %w", arg, err) + } -func setuplogger(cfg *devCfg, out io.Writer) *slog.Logger { - level := slog.LevelInfo - if cfg.verbose { - level = slog.LevelDebug - } + // Assign a default creator if user haven't specified it. + if path.Creator.IsZero() { + path.Creator = defaultKey + } - if cfg.serverMode { - zaplogger := logger.NewZapLogger(out, level) - return zaplog.ZapLoggerToSlog(zaplogger) + paths = append(paths, path) } - // Detect term color profile - colorProfile := termenv.DefaultOutput().Profile - clogger := logger.NewColumnLogger(out, level, colorProfile) - - // Register well known group color with system colors - clogger.RegisterGroupColor(NodeLogName, lipgloss.Color("3")) - clogger.RegisterGroupColor(WebLogName, lipgloss.Color("4")) - clogger.RegisterGroupColor(KeyPressLogName, lipgloss.Color("5")) - clogger.RegisterGroupColor(EventServerLogName, lipgloss.Color("6")) + // Add examples folder if minimal is set to false + if !cfg.minimal { + paths = append(paths, gnodev.PackagePath{ + Path: filepath.Join(cfg.root, "examples"), + Creator: defaultKey, + Deposit: nil, + }) + } - return slog.New(clogger) + return paths, nil } diff --git a/contribs/gnodev/cmd/gnodev/setup_address_book.go b/contribs/gnodev/cmd/gnodev/setup_address_book.go new file mode 100644 index 00000000000..a1a1c8f58ac --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/setup_address_book.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "log/slog" + + "github.com/gnolang/gno/contribs/gnodev/pkg/address" + "github.com/gnolang/gno/tm2/pkg/crypto" + osm "github.com/gnolang/gno/tm2/pkg/os" +) + +func setupAddressBook(logger *slog.Logger, cfg *devCfg) (*address.Book, error) { + book := address.NewBook() + + // Check for home folder + if cfg.home == "" { + logger.Warn("home not specified, no keybase will be loaded") + } else if !osm.DirExists(cfg.home) { + logger.Warn("keybase directory does not exist, no local keys will be imported", + "path", cfg.home) + } else if err := book.ImportKeybase(cfg.home); err != nil { + return nil, fmt.Errorf("unable to import local keybase %q: %w", cfg.home, err) + } + + // Add additional accounts to our keybase + for acc := range cfg.premineAccounts { + if _, ok := book.GetByName(acc); ok { + continue // we already know this account from keybase + } + + // Check if we have a valid bech32 address instead + addr, err := crypto.AddressFromBech32(acc) + if err != nil { + return nil, fmt.Errorf("invalid bech32 address or unknown keyname %q", acc) + } + + book.Add(addr, "") // add addr to the book with no name + + logger.Info("additional account added", "addr", addr.String()) + } + + // Ensure that we have a default address + names, ok := book.GetByAddress(DefaultDeployerAddress) + if ok { + // Account already exist in the keybase + if len(names) > 0 && names[0] != "" { + logger.Info("default address imported", "name", names[0], "addr", DefaultDeployerAddress.String()) + } else { + logger.Info("default address imported", "addr", DefaultDeployerAddress.String()) + } + return book, nil + } + + // If the key isn't found, create a default one + creatorName := fmt.Sprintf("_default#%.6s", DefaultDeployerAddress.String()) + book.Add(DefaultDeployerAddress, creatorName) + + logger.Warn("default address created", + "name", creatorName, + "addr", DefaultDeployerAddress.String(), + "mnemonic", DefaultDeployerSeed, + ) + + return book, nil +} diff --git a/contribs/gnodev/cmd/gnodev/setup_node.go b/contribs/gnodev/cmd/gnodev/setup_node.go new file mode 100644 index 00000000000..c79ab9d18bf --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/setup_node.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net" + "strings" + + 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" +) + +// 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, +) (*gnodev.Node, error) { + config := setupDevNodeConfig(cfg, balances, pkgspath) + return gnodev.NewDevNode(ctx, logger, remitter, config) +} + +// setupDevNodeConfig creates and returns a new dev.NodeConfig. +func setupDevNodeConfig(cfg *devCfg, balances gnoland.Balances, pkgspath []gnodev.PackagePath) *gnodev.NodeConfig { + config := gnodev.DefaultNodeConfig(cfg.root) + 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 + + // other listeners + config.TMConfig.P2P.ListenAddress = defaultDevOptions.nodeP2PListenerAddr + config.TMConfig.ProxyApp = defaultDevOptions.nodeProxyAppListenerAddr + + return config +} + +func resolveUnixOrTCPAddr(in string) (out string) { + var err error + var addr net.Addr + + if strings.HasPrefix(in, "unix://") { + in = strings.TrimPrefix(in, "unix://") + if addr, err := net.ResolveUnixAddr("unix", in); err == nil { + return fmt.Sprintf("%s://%s", addr.Network(), addr.String()) + } + + err = fmt.Errorf("unable to resolve unix address `unix://%s`: %w", in, err) + } else { // don't bother to checking prefix + in = strings.TrimPrefix(in, "tcp://") + if addr, err = net.ResolveTCPAddr("tcp", in); err == nil { + return fmt.Sprintf("%s://%s", addr.Network(), addr.String()) + } + + err = fmt.Errorf("unable to resolve tcp address `tcp://%s`: %w", in, err) + } + + panic(err) +} diff --git a/contribs/gnodev/cmd/gnodev/setup_term.go b/contribs/gnodev/cmd/gnodev/setup_term.go new file mode 100644 index 00000000000..1f8f3046969 --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/setup_term.go @@ -0,0 +1,24 @@ +package main + +import ( + "github.com/gnolang/gno/contribs/gnodev/pkg/rawterm" + "github.com/gnolang/gno/tm2/pkg/commands" +) + +var noopRestore = func() error { return nil } + +func setupRawTerm(cfg *devCfg, io commands.IO) (*rawterm.RawTerm, func() error, error) { + rt := rawterm.NewRawTerm() + restore := noopRestore + if !cfg.serverMode { + var err error + restore, err = rt.Init() + if err != nil { + return nil, nil, err + } + } + + // correctly format output for terminal + io.SetOut(commands.WriteNopCloser(rt)) + return rt, restore, nil +} diff --git a/contribs/gnodev/cmd/gnodev/setup_web.go b/contribs/gnodev/cmd/gnodev/setup_web.go new file mode 100644 index 00000000000..17df502c3d8 --- /dev/null +++ b/contribs/gnodev/cmd/gnodev/setup_web.go @@ -0,0 +1,20 @@ +package main + +import ( + "log/slog" + "net/http" + + gnodev "github.com/gnolang/gno/contribs/gnodev/pkg/dev" + "github.com/gnolang/gno/gno.land/pkg/gnoweb" +) + +// setupGnowebServer initializes and starts the Gnoweb server. +func setupGnoWebServer(logger *slog.Logger, cfg *devCfg, dnode *gnodev.Node) http.Handler { + webConfig := gnoweb.NewDefaultConfig() + webConfig.RemoteAddr = dnode.GetRemoteAddress() + webConfig.HelpRemote = dnode.GetRemoteAddress() + webConfig.HelpChainID = cfg.chainId + + app := gnoweb.MakeApp(logger, webConfig) + return app.Router +} diff --git a/contribs/gnodev/go.mod b/contribs/gnodev/go.mod index 90bef4b2b13..8b66f72d288 100644 --- a/contribs/gnodev/go.mod +++ b/contribs/gnodev/go.mod @@ -11,6 +11,7 @@ require ( github.com/gnolang/gno v0.0.0-00010101000000-000000000000 github.com/gorilla/websocket v1.5.1 github.com/muesli/termenv v0.15.2 + github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.27.0 golang.org/x/term v0.18.0 ) @@ -48,7 +49,6 @@ require ( github.com/rivo/uniseg v0.4.3 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rs/cors v1.10.1 // indirect - github.com/stretchr/testify v1.9.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect diff --git a/contribs/gnodev/pkg/address/book.go b/contribs/gnodev/pkg/address/book.go new file mode 100644 index 00000000000..983fced5882 --- /dev/null +++ b/contribs/gnodev/pkg/address/book.go @@ -0,0 +1,144 @@ +package address + +import ( + "fmt" + "sort" + + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/crypto/keys" +) + +// Book reference a list of addresses optionally associated with a name +// It is not thread safe. +type Book struct { + addrsToNames map[crypto.Address][]string // address -> []names + namesToAddrs map[string]crypto.Address // name -> address +} + +func NewBook() *Book { + return &Book{ + addrsToNames: map[crypto.Address][]string{}, + namesToAddrs: map[string]crypto.Address{}, + } +} + +// Add inserts a new address into the address book linked to the specified name. +// An address can be associated with multiple names, yet each name can only +// belong to one address. Hence, if a name is reused, it will replace the +// reference to the previous address. +// Adding an address without a name is permissible. +func (bk *Book) Add(addr crypto.Address, name string) { + if addr.IsZero() { + panic("empty address not allowed") + } + + // Check and register address if it wasn't existing + names, ok := bk.addrsToNames[addr] + if !ok { + bk.addrsToNames[addr] = []string{} + } + + // If name is empty, stop here + if name == "" { + return + } + + oldAddr, ok := bk.namesToAddrs[name] + if !ok { + bk.namesToAddrs[name] = addr + bk.addrsToNames[addr] = append(names, name) + return + } + + // Check if the association already exist + if oldAddr.Compare(addr) == 0 { + return // nothing to do + } + + // If the name is associated with a different address, remove the old association + oldNames := bk.addrsToNames[oldAddr] + for i, oldName := range oldNames { + if oldName == name { + bk.addrsToNames[oldAddr] = remove(oldNames, i) + break + } + } + + // Add the new association + bk.namesToAddrs[name] = addr + bk.addrsToNames[addr] = append(names, name) +} + +type Entry struct { + crypto.Address + Names []string +} + +func (bk Book) List() []Entry { + entries := make([]Entry, 0, len(bk.addrsToNames)) + for addr, names := range bk.addrsToNames { + namesCopy := make([]string, len(names)) + copy(namesCopy, names) + + newEntry := Entry{ + Address: addr, + Names: namesCopy, + } + + // Find the correct place to insert newEntry using binary search. + i := sort.Search(len(entries), func(i int) bool { + return entries[i].Address.Compare(newEntry.Address) >= 0 + }) + + entries = append(entries[:i], append([]Entry{newEntry}, entries[i:]...)...) + } + + return entries +} + +func (bk Book) GetByAddress(addr crypto.Address) (names []string, ok bool) { + names, ok = bk.addrsToNames[addr] + return +} + +func (bk Book) GetByName(name string) (addr crypto.Address, ok bool) { + addr, ok = bk.namesToAddrs[name] + return +} + +func (bk Book) GetFromNameOrAddress(addrOrName string) (addr crypto.Address, names []string, ok bool) { + var err error + if addr, ok = bk.namesToAddrs[addrOrName]; ok { + names = []string{addrOrName} + } else if addr, err = crypto.AddressFromBech32(addrOrName); err == nil { + // addr is valid, now check if we have it + names, ok = bk.addrsToNames[addr] + } + + return +} + +func (bk Book) ImportKeybase(path string) error { + kb, err := keys.NewKeyBaseFromDir(path) + if err != nil { + return fmt.Errorf("unable to load keybase: %w", err) + } + defer kb.CloseDB() + + keys, err := kb.List() + if err != nil { + return fmt.Errorf("unable to list keys: %w", err) + } + + for _, key := range keys { + name := key.GetName() + bk.Add(key.GetAddress(), name) + } + + return nil +} + +func remove(s []string, i int) []string { + s[len(s)-1], s[i] = s[i], s[len(s)-1] + return s[:len(s)-1] +} diff --git a/contribs/gnodev/pkg/address/book_test.go b/contribs/gnodev/pkg/address/book_test.go new file mode 100644 index 00000000000..80249762455 --- /dev/null +++ b/contribs/gnodev/pkg/address/book_test.go @@ -0,0 +1,117 @@ +package address + +import ( + "testing" + + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testAddr = crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + +func TestNewBook(t *testing.T) { + t.Parallel() + + bk := NewBook() + assert.Empty(t, bk.addrsToNames) + assert.Empty(t, bk.namesToAddrs) +} + +func TestAddEmptyName(t *testing.T) { + t.Parallel() + + bk := NewBook() + + // Add address + bk.Add(testAddr, "") + + names, ok := bk.GetByAddress(testAddr) + require.True(t, ok) + require.Equal(t, 0, len(names)) +} + +func TestAdd(t *testing.T) { + t.Parallel() + + bk := NewBook() + + // Add address + bk.Add(testAddr, "testname") + + t.Run("get by address", func(t *testing.T) { + t.Parallel() + + names, ok := bk.GetByAddress(testAddr) + require.True(t, ok) + require.Equal(t, 1, len(names)) + assert.Equal(t, "testname", names[0]) + }) + + t.Run("get by name", func(t *testing.T) { + t.Parallel() + + addrFromName, ok := bk.GetByName("testname") + assert.True(t, ok) + assert.True(t, addrFromName.Compare(testAddr) == 0) + }) + + // Add same address with a new name + bk.Add(testAddr, "testname2") + + t.Run("get two names with same address", func(t *testing.T) { + t.Parallel() + + // Get by name + addr1, ok := bk.GetByName("testname") + require.True(t, ok) + addr2, ok := bk.GetByName("testname2") + require.True(t, ok) + assert.True(t, addr1.Compare(addr2) == 0) + }) +} + +func TestList(t *testing.T) { + t.Parallel() + + bk := NewBook() + + bk.Add(testAddr, "testname") + + entries := bk.List() + + assert.Equal(t, 1, len(entries)) + entry := entries[0] + + assert.True(t, testAddr.Compare(entry.Address) == 0) + assert.Equal(t, 1, len(entries[0].Names)) + assert.Equal(t, "testname", entries[0].Names[0]) +} + +func TestGetFromNameOrAddress(t *testing.T) { + t.Parallel() + + bk := NewBook() + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + resultAddr, names, ok := bk.GetFromNameOrAddress("unknown_key") + assert.False(t, ok) + assert.True(t, resultAddr.IsZero()) + assert.Len(t, names, 0) + }) + + // Add address + bk.Add(testAddr, "testname") + + for _, addrOrName := range []string{"testname", testAddr.String()} { + t.Run(addrOrName, func(t *testing.T) { + resultAddr, names, ok := bk.GetFromNameOrAddress("testname") + require.True(t, ok) + require.Len(t, names, 1) + assert.Equal(t, "testname", names[0]) + assert.True(t, resultAddr.Compare(testAddr) == 0) + }) + } +} diff --git a/contribs/gnodev/pkg/dev/node.go b/contribs/gnodev/pkg/dev/node.go index 02d97dff733..66971980b73 100644 --- a/contribs/gnodev/pkg/dev/node.go +++ b/contribs/gnodev/pkg/dev/node.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "path/filepath" "strings" "unicode" @@ -27,22 +28,36 @@ import ( ) type NodeConfig struct { - PackagesPathList []string - TMConfig *tmcfg.Config - NoReplay bool - MaxGasPerBlock int64 - ChainID string + DefaultDeployer crypto.Address + BalancesList []gnoland.Balance + PackagesPathList []PackagePath + TMConfig *tmcfg.Config + SkipFailingGenesisTxs bool + NoReplay bool + MaxGasPerBlock int64 + ChainID string } func DefaultNodeConfig(rootdir string) *NodeConfig { tmc := gnoland.NewDefaultTMConfig(rootdir) tmc.Consensus.SkipTimeoutCommit = false // avoid time drifting, see issue #1507 + defaultDeployer := crypto.MustAddressFromString(integration.DefaultAccount_Address) + balances := []gnoland.Balance{ + { + Address: defaultDeployer, + Amount: std.Coins{std.NewCoin("ugnot", 10e12)}, + }, + } + return &NodeConfig{ - ChainID: tmc.ChainID(), - PackagesPathList: []string{}, - TMConfig: tmc, - MaxGasPerBlock: 10_000_000_000, + DefaultDeployer: defaultDeployer, + BalancesList: balances, + ChainID: tmc.ChainID(), + PackagesPathList: []PackagePath{}, + TMConfig: tmc, + SkipFailingGenesisTxs: true, + MaxGasPerBlock: 10_000_000_000, } } @@ -54,30 +69,21 @@ type Node struct { emitter emitter.Emitter client client.Client logger *slog.Logger - pkgs PkgsMap // path -> pkg + pkgs PackagesMap // path -> pkg // keep track of number of loaded package to be able to skip them on restore loadedPackages int } -var ( - DefaultFee = std.NewFee(50000, std.MustParseCoin("1000000ugnot")) - DefaultCreator = crypto.MustAddressFromString(integration.DefaultAccount_Address) - DefaultBalance = []gnoland.Balance{ - { - Address: DefaultCreator, - Amount: std.MustParseCoins("10000000000000ugnot"), - }, - } -) +var DefaultFee = std.NewFee(50000, std.MustParseCoin("1000000ugnot")) func NewDevNode(ctx context.Context, logger *slog.Logger, emitter emitter.Emitter, cfg *NodeConfig) (*Node, error) { - mpkgs, err := newPkgsMap(cfg.PackagesPathList) + mpkgs, err := NewPackagesMap(cfg.PackagesPathList) if err != nil { return nil, fmt.Errorf("unable map pkgs list: %w", err) } - pkgsTxs, err := mpkgs.Load(DefaultCreator, DefaultFee, nil) + pkgsTxs, err := mpkgs.Load(DefaultFee) if err != nil { return nil, fmt.Errorf("unable to load genesis packages: %w", err) } @@ -86,7 +92,7 @@ func NewDevNode(ctx context.Context, logger *slog.Logger, emitter emitter.Emitte // generate genesis state genesis := gnoland.GnoGenesisState{ - Balances: DefaultBalance, + Balances: cfg.BalancesList, Txs: pkgsTxs, } @@ -106,145 +112,166 @@ func NewDevNode(ctx context.Context, logger *slog.Logger, emitter emitter.Emitte return devnode, nil } -func (d *Node) getLatestBlockNumber() uint64 { - return uint64(d.Node.BlockStore().Height()) +func (n *Node) getLatestBlockNumber() uint64 { + return uint64(n.Node.BlockStore().Height()) } -func (d *Node) Close() error { - return d.Node.Stop() +func (n *Node) Close() error { + return n.Node.Stop() } -func (d *Node) ListPkgs() []gnomod.Pkg { - return d.pkgs.toList() +func (n *Node) ListPkgs() []gnomod.Pkg { + return n.pkgs.toList() } -func (d *Node) GetNodeReadiness() <-chan struct{} { - return gnoland.GetNodeReadiness(d.Node) +func (n *Node) GetNodeReadiness() <-chan struct{} { + return gnoland.GetNodeReadiness(n.Node) } -func (d *Node) GetRemoteAddress() string { - return d.Node.Config().RPC.ListenAddress +func (n *Node) GetRemoteAddress() string { + return n.Node.Config().RPC.ListenAddress } // UpdatePackages updates the currently known packages. It will be taken into // consideration in the next reload of the node. -func (d *Node) UpdatePackages(paths ...string) error { - var n int +func (n *Node) UpdatePackages(paths ...string) error { + var i int for _, path := range paths { + abspath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("unable to resolve abs path of %q: %w", path, err) + } + + deployer := n.config.DefaultDeployer + var deposit std.Coins + for _, ppath := range n.config.PackagesPathList { + if !strings.HasPrefix(abspath, ppath.Path) { + continue + } + + deployer = ppath.Creator + deposit = ppath.Deposit + } + // List all packages from target path - pkgslist, err := gnomod.ListPkgs(path) + pkgslist, err := gnomod.ListPkgs(abspath) if err != nil { return fmt.Errorf("failed to list gno packages for %q: %w", path, err) } // Update or add package in the current known list. for _, pkg := range pkgslist { - d.pkgs[pkg.Dir] = pkg - d.logger.Debug("pkgs update", "name", pkg.Name, "path", pkg.Dir) + n.pkgs[pkg.Dir] = Package{ + Pkg: pkg, + Creator: deployer, + Deposit: deposit, + } + + n.logger.Debug("pkgs update", "name", pkg.Name, "path", pkg.Dir) } - n += len(pkgslist) + i += len(pkgslist) } - d.logger.Info(fmt.Sprintf("updated %d pacakges", n)) + n.logger.Info(fmt.Sprintf("updated %d pacakges", i)) return nil } // Reset stops the node, if running, and reloads it with a new genesis state, // effectively ignoring the current state. -func (d *Node) Reset(ctx context.Context) error { +func (n *Node) Reset(ctx context.Context) error { // Stop the node if it's currently running. - if err := d.stopIfRunning(); err != nil { + if err := n.stopIfRunning(); err != nil { return fmt.Errorf("unable to stop the node: %w", err) } // Generate a new genesis state based on the current packages - txs, err := d.pkgs.Load(DefaultCreator, DefaultFee, nil) + txs, err := n.pkgs.Load(DefaultFee) if err != nil { return fmt.Errorf("unable to load pkgs: %w", err) } genesis := gnoland.GnoGenesisState{ - Balances: DefaultBalance, + Balances: n.config.BalancesList, Txs: txs, } // Reset the node with the new genesis state. - err = d.reset(ctx, genesis) + err = n.reset(ctx, genesis) if err != nil { return fmt.Errorf("unable to initialize a new node: %w", err) } - d.emitter.Emit(&events.Reset{}) + n.emitter.Emit(&events.Reset{}) return nil } // ReloadAll updates all currently known packages and then reloads the node. -func (d *Node) ReloadAll(ctx context.Context) error { - pkgs := d.ListPkgs() +func (n *Node) ReloadAll(ctx context.Context) error { + pkgs := n.ListPkgs() paths := make([]string, len(pkgs)) for i, pkg := range pkgs { paths[i] = pkg.Dir } - if err := d.UpdatePackages(paths...); err != nil { + if err := n.UpdatePackages(paths...); err != nil { return fmt.Errorf("unable to reload packages: %w", err) } - return d.Reload(ctx) + return n.Reload(ctx) } // Reload saves the current state, stops the node if running, starts a new node, // and re-apply previously saved state along with packages updated by `UpdatePackages`. // If any transaction, including 'addpkg', fails, it will be ignored. // Use 'Reset' to completely reset the node's state in case of persistent errors. -func (d *Node) Reload(ctx context.Context) error { - if d.config.NoReplay { +func (n *Node) Reload(ctx context.Context) error { + if n.config.NoReplay { // If NoReplay is true, reload as the same effect as reset - d.logger.Warn("replay disable") - return d.Reset(ctx) + n.logger.Warn("replay disable") + return n.Reset(ctx) } // Get current blockstore state - state, err := d.getBlockStoreState(ctx) + state, err := n.getBlockStoreState(ctx) if err != nil { return fmt.Errorf("unable to save state: %s", err.Error()) } // Stop the node if it's currently running. - if err := d.stopIfRunning(); err != nil { + if err := n.stopIfRunning(); err != nil { return fmt.Errorf("unable to stop the node: %w", err) } // Load genesis packages - pkgsTxs, err := d.pkgs.Load(DefaultCreator, DefaultFee, nil) + pkgsTxs, err := n.pkgs.Load(DefaultFee) if err != nil { return fmt.Errorf("unable to load pkgs: %w", err) } // Create genesis with loaded pkgs + previous state genesis := gnoland.GnoGenesisState{ - Balances: DefaultBalance, + Balances: n.config.BalancesList, Txs: append(pkgsTxs, state...), } // Reset the node with the new genesis state. - err = d.reset(ctx, genesis) - d.logger.Info("reload done", "pkgs", len(pkgsTxs), "state applied", len(state)) + err = n.reset(ctx, genesis) + n.logger.Info("reload done", "pkgs", len(pkgsTxs), "state applied", len(state)) // Update node infos - d.loadedPackages = len(pkgsTxs) + n.loadedPackages = len(pkgsTxs) - d.emitter.Emit(&events.Reload{}) + n.emitter.Emit(&events.Reload{}) return nil } -func (d *Node) genesisTxHandler(ctx sdk.Context, tx std.Tx, res sdk.Result) { +func (n *Node) genesisTxHandler(ctx sdk.Context, tx std.Tx, res sdk.Result) { if res.IsErr() { // XXX: for now, this is only way to catch the error before, after, found := strings.Cut(res.Log, "\n") if !found { - d.logger.Error("unable to send tx", "err", res.Error, "log", res.Log) + n.logger.Error("unable to send tx", "err", res.Error, "log", res.Log) return } @@ -260,20 +287,19 @@ func (d *Node) genesisTxHandler(ctx sdk.Context, tx std.Tx, res sdk.Result) { attrs = append(attrs, slog.String("err", msg)) // If debug is enable, also append stack - if d.logger.Enabled(context.Background(), slog.LevelDebug) { + if n.logger.Enabled(context.Background(), slog.LevelDebug) { attrs = append(attrs, slog.String("stack", after)) - } - d.logger.LogAttrs(context.Background(), slog.LevelError, "unable to deliver tx", attrs...) + n.logger.LogAttrs(context.Background(), slog.LevelError, "unable to deliver tx", attrs...) } } // GetBlockTransactions returns the transactions contained // within the specified block, if any -func (d *Node) GetBlockTransactions(blockNum uint64) ([]std.Tx, error) { +func (n *Node) GetBlockTransactions(blockNum uint64) ([]std.Tx, error) { int64BlockNum := int64(blockNum) - b, err := d.client.Block(&int64BlockNum) + b, err := n.client.Block(&int64BlockNum) if err != nil { return []std.Tx{}, fmt.Errorf("unable to load block at height %d: %w", blockNum, err) // nothing to see here } @@ -291,34 +317,40 @@ func (d *Node) GetBlockTransactions(blockNum uint64) ([]std.Tx, error) { return txs, nil } +// GetBlockTransactions returns the transactions contained +// within the specified block, if any +func (n *Node) CurrentBalances(blockNum uint64) ([]std.Tx, error) { + return nil, nil +} + // GetBlockTransactions returns the transactions contained // within the specified block, if any // GetLatestBlockNumber returns the latest block height from the chain -func (d *Node) GetLatestBlockNumber() (uint64, error) { - return d.getLatestBlockNumber(), nil +func (n *Node) GetLatestBlockNumber() (uint64, error) { + return n.getLatestBlockNumber(), nil } // SendTransaction executes a broadcast commit send // of the specified transaction to the chain -func (d *Node) SendTransaction(tx *std.Tx) error { +func (n *Node) SendTransaction(tx *std.Tx) error { aminoTx, err := amino.Marshal(tx) if err != nil { return fmt.Errorf("unable to marshal transaction to amino binary, %w", err) } // we use BroadcastTxCommit to ensure to have one block with the given tx - res, err := d.client.BroadcastTxCommit(aminoTx) + res, err := n.client.BroadcastTxCommit(aminoTx) if err != nil { return fmt.Errorf("unable to broadcast transaction commit: %w", err) } if res.CheckTx.Error != nil { - d.logger.Error("check tx error trace", "log", res.CheckTx.Log) + n.logger.Error("check tx error trace", "log", res.CheckTx.Log) return fmt.Errorf("check transaction error: %w", res.CheckTx.Error) } if res.DeliverTx.Error != nil { - d.logger.Error("deliver tx error trace", "log", res.CheckTx.Log) + n.logger.Error("deliver tx error trace", "log", res.CheckTx.Log) return fmt.Errorf("deliver transaction error: %w", res.DeliverTx.Error) } diff --git a/contribs/gnodev/pkg/dev/packages.go b/contribs/gnodev/pkg/dev/packages.go new file mode 100644 index 00000000000..f58775277e9 --- /dev/null +++ b/contribs/gnodev/pkg/dev/packages.go @@ -0,0 +1,160 @@ +package dev + +import ( + "errors" + "fmt" + "net/url" + "path/filepath" + + "github.com/gnolang/gno/contribs/gnodev/pkg/address" + vmm "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + gno "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/gnolang/gno/gnovm/pkg/gnomod" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/std" +) + +type PackagePath struct { + Path string + Creator crypto.Address + Deposit std.Coins +} + +func ResolvePackagePathQuery(bk *address.Book, path string) (PackagePath, error) { + var ppath PackagePath + + upath, err := url.Parse(path) + if err != nil { + return ppath, fmt.Errorf("malformed path/query: %w", err) + } + ppath.Path = upath.Path + + // Check for creator option + creator := upath.Query().Get("creator") + if creator != "" { + address, err := crypto.AddressFromBech32(creator) + if err != nil { + var ok bool + address, ok = bk.GetByName(creator) + if !ok { + return ppath, fmt.Errorf("invalid name or address for creator %q", creator) + } + } + + ppath.Creator = address + } + + // Check for deposit option + deposit := upath.Query().Get("deposit") + if deposit != "" { + coins, err := std.ParseCoins(deposit) + if err != nil { + return ppath, fmt.Errorf( + "unable to parse deposit amount %q (should be in the form xxxugnot): %w", deposit, err, + ) + } + + ppath.Deposit = coins + } + + return ppath, nil +} + +type Package struct { + gnomod.Pkg + Creator crypto.Address + Deposit std.Coins +} + +type PackagesMap map[string]Package + +var ( + ErrEmptyCreatorPackage = errors.New("no creator specified for package") + ErrEmptyDepositPackage = errors.New("no deposit specified for package") +) + +func NewPackagesMap(ppaths []PackagePath) (PackagesMap, error) { + pkgs := make(map[string]Package) + for _, ppath := range ppaths { + if ppath.Creator.IsZero() { + return nil, fmt.Errorf("unable to load package %q: %w", ppath.Path, ErrEmptyCreatorPackage) + } + + abspath, err := filepath.Abs(ppath.Path) + if err != nil { + return nil, fmt.Errorf("unable to guess absolute path for %q: %w", ppath.Path, err) + } + + // list all packages from target path + pkgslist, err := gnomod.ListPkgs(abspath) + if err != nil { + return nil, fmt.Errorf("listing gno packages: %w", err) + } + + for _, pkg := range pkgslist { + if pkg.Dir == "" { + continue + } + + if _, ok := pkgs[pkg.Dir]; ok { + continue // skip + } + pkgs[pkg.Dir] = Package{ + Pkg: pkg, + Creator: ppath.Creator, + Deposit: ppath.Deposit, + } + } + } + + return pkgs, nil +} + +func (pm PackagesMap) toList() gnomod.PkgList { + list := make([]gnomod.Pkg, 0, len(pm)) + for _, pkg := range pm { + list = append(list, pkg.Pkg) + } + return list +} + +func (pm PackagesMap) Load(fee std.Fee) ([]std.Tx, error) { + pkgs := pm.toList() + + sorted, err := pkgs.Sort() + if err != nil { + return nil, fmt.Errorf("unable to sort pkgs: %w", err) + } + + nonDraft := sorted.GetNonDraftPkgs() + txs := []std.Tx{} + for _, modPkg := range nonDraft { + pkg := pm[modPkg.Dir] + if pkg.Creator.IsZero() { + return nil, fmt.Errorf("no creator set for %q", pkg.Dir) + } + + // Open files in directory as MemPackage. + memPkg := gno.ReadMemPackage(modPkg.Dir, modPkg.Name) + if err := memPkg.Validate(); err != nil { + return nil, fmt.Errorf("invalid package: %w", err) + } + + // Create transaction + tx := std.Tx{ + Fee: fee, + Msgs: []std.Msg{ + vmm.MsgAddPackage{ + Creator: pkg.Creator, + Deposit: pkg.Deposit, + Package: memPkg, + }, + }, + } + + tx.Signatures = make([]std.Signature, len(tx.GetSigners())) + txs = append(txs, tx) + } + + return txs, nil +} diff --git a/contribs/gnodev/pkg/dev/packages_test.go b/contribs/gnodev/pkg/dev/packages_test.go new file mode 100644 index 00000000000..dbae99b4484 --- /dev/null +++ b/contribs/gnodev/pkg/dev/packages_test.go @@ -0,0 +1,72 @@ +package dev + +import ( + "testing" + + "github.com/gnolang/gno/contribs/gnodev/pkg/address" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolvePackagePathQuery(t *testing.T) { + t.Parallel() + + var ( + testingName = "testAccount" + testingAddress = crypto.MustAddressFromString("g1hr3dl82qdy84a5h3dmckh0suc7zgwm5rnns6na") + ) + + book := address.NewBook() + book.Add(testingAddress, testingName) + + cases := []struct { + Path string + ExpectedPackagePath PackagePath + ShouldFail bool + }{ + {".", PackagePath{ + Path: ".", + }, false}, + {"/simple/path", PackagePath{ + Path: "/simple/path", + }, false}, + {"/ambiguo/u//s/path///", PackagePath{ + Path: "/ambiguo/u/s/path", + }, false}, + {"/path/with/deployer?deployer=testAccount", PackagePath{ + Path: "/path/with/deployer", + Creator: testingAddress, + }, false}, + {"/path/with/deposit?deposit=100ugnot", PackagePath{ + Path: "/path/with/deposit", + Deposit: std.MustParseCoins("100ugnot"), + }, false}, + {".?deployer=g1hr3dl82qdy84a5h3dmckh0suc7zgwm5rnns6na&deposit=100ugnot", PackagePath{ + Path: ".", + Creator: testingAddress, + Deposit: std.MustParseCoins("100ugnot"), + }, false}, + + // errors cases + {"/invalid/account?deployer=UnknownAccount", PackagePath{}, true}, + {"/invalid/address?deployer=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", PackagePath{}, true}, + {"/invalid/deposit?deposit=abcd", PackagePath{}, true}, + } + + for _, tc := range cases { + t.Run(tc.Path, func(t *testing.T) { + result, err := ResolvePackagePathQuery(book, tc.Path) + if tc.ShouldFail { + assert.Error(t, err) + return + } + require.NoError(t, err) + + assert.Equal(t, tc.ExpectedPackagePath.Path, result.Path) + assert.Equal(t, tc.ExpectedPackagePath.Creator, result.Creator) + assert.Equal(t, tc.ExpectedPackagePath.Deposit.String(), result.Deposit.String()) + }) + } +} diff --git a/contribs/gnodev/pkg/dev/pkgs.go b/contribs/gnodev/pkg/dev/pkgs.go deleted file mode 100644 index c02508ff33d..00000000000 --- a/contribs/gnodev/pkg/dev/pkgs.go +++ /dev/null @@ -1,82 +0,0 @@ -package dev - -import ( - "fmt" - - vmm "github.com/gnolang/gno/gno.land/pkg/sdk/vm" - gno "github.com/gnolang/gno/gnovm/pkg/gnolang" - "github.com/gnolang/gno/gnovm/pkg/gnomod" - bft "github.com/gnolang/gno/tm2/pkg/bft/types" - "github.com/gnolang/gno/tm2/pkg/std" -) - -type PkgsMap map[string]gnomod.Pkg - -func newPkgsMap(paths []string) (PkgsMap, error) { - pkgs := make(map[string]gnomod.Pkg) - for _, path := range paths { - // list all packages from target path - pkgslist, err := gnomod.ListPkgs(path) - if err != nil { - return nil, fmt.Errorf("listing gno packages: %w", err) - } - - for _, pkg := range pkgslist { - if pkg.Dir == "" { - continue - } - - if _, ok := pkgs[pkg.Dir]; ok { - continue // skip - } - pkgs[pkg.Dir] = pkg - } - } - - // Filter out draft packages. - return pkgs, nil -} - -func (pm PkgsMap) toList() gnomod.PkgList { - list := make([]gnomod.Pkg, 0, len(pm)) - for _, pkg := range pm { - list = append(list, pkg) - } - return list -} - -func (pm PkgsMap) Load(creator bft.Address, fee std.Fee, deposit std.Coins) ([]std.Tx, error) { - pkgs := pm.toList() - - sorted, err := pkgs.Sort() - if err != nil { - return nil, fmt.Errorf("unable to sort pkgs: %w", err) - } - - nonDraft := sorted.GetNonDraftPkgs() - txs := []std.Tx{} - for _, pkg := range nonDraft { - // Open files in directory as MemPackage. - memPkg := gno.ReadMemPackage(pkg.Dir, pkg.Name) - if err := memPkg.Validate(); err != nil { - return nil, fmt.Errorf("invalid package: %w", err) - } - - // Create transaction - tx := std.Tx{ - Fee: fee, - Msgs: []std.Msg{ - vmm.MsgAddPackage{ - Creator: creator, - Package: memPkg, - Deposit: deposit, - }, - }, - } - - tx.Signatures = make([]std.Signature, len(tx.GetSigners())) - txs = append(txs, tx) - } - - return txs, nil -} diff --git a/contribs/gnodev/pkg/emitter/middleware.go b/contribs/gnodev/pkg/emitter/middleware.go index 80c07ec93aa..9c53cfe158e 100644 --- a/contribs/gnodev/pkg/emitter/middleware.go +++ b/contribs/gnodev/pkg/emitter/middleware.go @@ -89,7 +89,6 @@ func (m *middleware) ServeHTTP(rw http.ResponseWriter, req *http.Request) { events.EvtReload, events.EvtReset, events.EvtTxResult, }, }) - if err != nil { panic("unable to execute template: " + err.Error()) } diff --git a/contribs/gnodev/pkg/logger/colors.go b/contribs/gnodev/pkg/logger/colors.go index b0499e01722..1f6ec097488 100644 --- a/contribs/gnodev/pkg/logger/colors.go +++ b/contribs/gnodev/pkg/logger/colors.go @@ -11,7 +11,7 @@ import ( func colorFromString(s string, saturation, lightness float64) lipgloss.Color { hue := float64(hash32a(s) % 360) - r, g, b := hslToRGB(float64(hue), saturation, lightness) + r, g, b := hslToRGB(hue, saturation, lightness) hex := rgbToHex(r, g, b) return lipgloss.Color(hex) } diff --git a/contribs/gnodev/pkg/logger/log_column.go b/contribs/gnodev/pkg/logger/log_column.go index 8e145e89175..2a720525903 100644 --- a/contribs/gnodev/pkg/logger/log_column.go +++ b/contribs/gnodev/pkg/logger/log_column.go @@ -141,7 +141,7 @@ func (cl *columnWriter) Write(buf []byte) (n int, err error) { buf = buf[todo:] if cl.inline = i < 0; !cl.inline { - if _, err = cl.writer.Write([]byte(lf)); err != nil { + if _, err = cl.writer.Write(lf); err != nil { return n, err } n++ diff --git a/contribs/gnodev/pkg/rawterm/keypress.go b/contribs/gnodev/pkg/rawterm/keypress.go index 20503476a9b..48f8367a65b 100644 --- a/contribs/gnodev/pkg/rawterm/keypress.go +++ b/contribs/gnodev/pkg/rawterm/keypress.go @@ -18,6 +18,7 @@ const ( KeyCtrlR KeyPress = '\x12' // Ctrl+R KeyCtrlT KeyPress = '\x14' // Ctrl+T + KeyA KeyPress = 'A' KeyH KeyPress = 'H' KeyR KeyPress = 'R' ) diff --git a/contribs/gnodev/pkg/watcher/watch.go b/contribs/gnodev/pkg/watcher/watch.go index a9ab189947f..63158a06c4b 100644 --- a/contribs/gnodev/pkg/watcher/watch.go +++ b/contribs/gnodev/pkg/watcher/watch.go @@ -61,7 +61,7 @@ func (p *PackageWatcher) startWatching() { defer close(pkgsUpdateChan) var debounceTimer <-chan time.Time - var pathList = []string{} + pathList := []string{} var err error for err == nil { diff --git a/docs/gno-tooling/cli/gnodev.md b/docs/gno-tooling/cli/gnodev.md index fae635ed5b7..97a4cc2e5ce 100644 --- a/docs/gno-tooling/cli/gnodev.md +++ b/docs/gno-tooling/cli/gnodev.md @@ -12,15 +12,18 @@ local instance of `gnoweb`, allowing you to see the rendering of your Gno code i ## Features - **In-Memory Node**: Gnodev starts an in-memory node, and automatically loads -the **examples** folder and any user-specified paths. + the **examples** folder and any user-specified paths. - **Web Interface Server**: Gnodev automatically starts a `gnoweb` server on [`localhost:8888`](https://localhost:8888). -- **Hot Reload**: Gnodev monitors the **examples** folder and any specified for file changes, -reloading and automatically restarting the node as needed. +- **Balances and Keybase Customization**: Users can set account balances, load them from a file, or add new + accounts via a flag. +- **Hot Reload**: Gnodev monitors the **examples** folder, as well as any folder specified as an argument for + 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. + ensuring the previous node state is preserved. ## Installation + Gnodev can be found in the `contribs` folder in the monorepo. To install `gnodev`, run `make install`. @@ -35,15 +38,86 @@ For hot reloading, `gnodev` watches the examples folder, as well as any specifie gnodev ./myrealm ``` +## Keybase and Balance + +Gnodev will, by default, load the keybase located in your GNOHOME directory, pre-mining `10e12` amount of +ugnot to all of them. This way, users can interact with Gnodev's in-memory node out of the box. The addresses +and their respective balance can be shown at runtime by pressing `A` to display accounts interactively. + +### Adding or Updating Accounts + +Utilize the `--add-account` flag to add a new account or update an existing one in your local Keybase, +following the format `[:]`. The `` represents the specific key name or +address, and `` is an optional limitation on the account. + +Example of use: + +``` +gnodev --add-account [:] --add-account [:] ... +``` + +Please note: If the address exists in your local Keybase, the `--add-account` flag will only update its amount, +instead of creating a duplicate. + +### Balance file + +You can specify a balance file using `--balance-file`. The file should contain a +list of Bech32 addresses with their respective amounts: + +``` +# Accounts: +g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5=10000000000000ugnot # test1 +g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj=10000000000000ugnot # test2 + +# ... +``` + +### Deploy + +All realms and packages will be deployed to the in-memory node by the address passed in with the +`--deploy-key` flag. The `deploy-key` address can be changed for a specific package or realm by passing in +the desired address (or a known key name) using with the following pattern: + +``` +gnodev ./myrealm?creator=g1.... +``` + +A specific deposit amount can also be set with the following pattern: + +``` +gnodev ./myrealm?deposit=42ugnot +``` + +This patten can be expanded to accommodate both options: + +``` +gnodev ./myrealm?creator=&deposit= +``` + +## Interactive Usage + 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 reset the state of the node, press `CMD+R`. -- To see help, press `H`. - To stop `gnodev`, press `CMD+C`. ### Options -| Flag | Effect | -|------------|-----------------------------------------------------| -| --minimal | Start `gnodev` without loading the examples folder. | -| --no-watch | Disable hot reload. | \ No newline at end of file +| Flag | Effect | +|---------------------|------------------------------------------------------------| +| --minimal | Start `gnodev` without loading the examples folder. | +| --no-watch | Disable hot reload. | +| --add-account | Pre-add account(s) in the form `[=]` | +| --balances-file | Load a balance for the user(s) from a balance file. | +| --chain-id | Set node ChainID | +| --deploy-key | Default key name or Bech32 address for uploading packages. | +| --home | Set the path to load user's Keybase. | +| --max-gas | Set the maximum gas per block | +| --no-replay | Do not replay previous transactions upon reload | +| --node-rpc-listener | listening address for GnoLand RPC node | +| --root | gno root directory | +| --server-mode | disable interaction, and adjust logging for server use. | +| --verbose | enable verbose output for development | +| --web-listener | web server listening address | diff --git a/gno.land/cmd/gnoland/genesis_balances_add.go b/gno.land/cmd/gnoland/genesis_balances_add.go index d1e88efcc6b..1497f4f6065 100644 --- a/gno.land/cmd/gnoland/genesis_balances_add.go +++ b/gno.land/cmd/gnoland/genesis_balances_add.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "os" - "strings" "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/tm2/pkg/amino" @@ -93,16 +92,16 @@ func execBalancesAdd(ctx context.Context, cfg *balancesAddCfg, io commands.IO) e return errNoBalanceSource } - finalBalances := make(accountBalances) + finalBalances := gnoland.NewBalances() // Get the balance sheet from the source if singleEntriesSet { - balances, err := getBalancesFromEntries(cfg.singleEntries) + balances, err := gnoland.GetBalancesFromEntries(cfg.singleEntries...) if err != nil { return fmt.Errorf("unable to get balances from entries, %w", err) } - finalBalances.leftMerge(balances) + finalBalances.LeftMerge(balances) } if balanceSheetSet { @@ -112,12 +111,12 @@ func execBalancesAdd(ctx context.Context, cfg *balancesAddCfg, io commands.IO) e return fmt.Errorf("unable to open balance sheet, %w", loadErr) } - balances, err := getBalancesFromSheet(file) + balances, err := gnoland.GetBalancesFromSheet(file) if err != nil { return fmt.Errorf("unable to get balances from balance sheet, %w", err) } - finalBalances.leftMerge(balances) + finalBalances.LeftMerge(balances) } if txFileSet { @@ -132,7 +131,7 @@ func execBalancesAdd(ctx context.Context, cfg *balancesAddCfg, io commands.IO) e return fmt.Errorf("unable to get balances from tx file, %w", err) } - finalBalances.leftMerge(balances) + finalBalances.LeftMerge(balances) } // Initialize genesis app state if it is not initialized already @@ -149,10 +148,10 @@ func execBalancesAdd(ctx context.Context, cfg *balancesAddCfg, io commands.IO) e // Merge the two balance sheets, with the input // having precedence over the genesis balances - finalBalances.leftMerge(genesisBalances) + finalBalances.LeftMerge(genesisBalances) // Save the balances - state.Balances = finalBalances.toList() + state.Balances = finalBalances.List() genesis.AppState = state // Save the updated genesis @@ -174,56 +173,6 @@ func execBalancesAdd(ctx context.Context, cfg *balancesAddCfg, io commands.IO) e return nil } -// getBalancesFromEntries extracts the balance entries -// from the array of balance -func getBalancesFromEntries(entries []string) (accountBalances, error) { - balances := make(accountBalances) - - for _, entry := range entries { - var balance gnoland.Balance - if err := balance.Parse(entry); err != nil { - return nil, fmt.Errorf("unable to parse balance entry: %w", err) - } - balances[balance.Address] = balance - } - - return balances, nil -} - -// getBalancesFromSheet extracts the balance sheet from the passed in -// balance sheet file, that has the format of
=ugnot -func getBalancesFromSheet(sheet io.Reader) (accountBalances, error) { - // Parse the balances - balances := make(accountBalances) - scanner := bufio.NewScanner(sheet) - - for scanner.Scan() { - entry := scanner.Text() - - // Remove comments - entry = strings.Split(entry, "#")[0] - entry = strings.TrimSpace(entry) - - // Skip empty lines - if entry == "" { - continue - } - - var balance gnoland.Balance - if err := balance.Parse(entry); err != nil { - return nil, fmt.Errorf("unable to extract balance data, %w", err) - } - - balances[balance.Address] = balance - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error encountered while scanning, %w", err) - } - - return balances, nil -} - // getBalancesFromTransactions constructs a balance map based on MsgSend messages. // This way of determining the final balance sheet is not valid, since it doesn't take into // account different message types (ex. MsgCall) that can initialize accounts with some balance values. @@ -234,8 +183,8 @@ func getBalancesFromTransactions( ctx context.Context, io commands.IO, reader io.Reader, -) (accountBalances, error) { - balances := make(accountBalances) +) (gnoland.Balances, error) { + balances := gnoland.NewBalances() scanner := bufio.NewScanner(reader) @@ -285,7 +234,7 @@ func getBalancesFromTransactions( // This way of determining final account balances is not really valid, // because we take into account only the ugnot transfer messages (MsgSend) // and not other message types (like MsgCall), that can also - // initialize accounts with some balances. Because of this, + // initialize accounts with some gnoland. Because of this, // we can run into a situation where a message send amount or fee // causes an accounts balance to go < 0. In these cases, // we initialize the account (it is present in the balance sheet), but @@ -336,9 +285,9 @@ func getBalancesFromTransactions( // mapGenesisBalancesFromState extracts the initial account balances from the // genesis app state -func mapGenesisBalancesFromState(state gnoland.GnoGenesisState) (accountBalances, error) { +func mapGenesisBalancesFromState(state gnoland.GnoGenesisState) (gnoland.Balances, error) { // Construct the initial genesis balance sheet - genesisBalances := make(accountBalances) + genesisBalances := gnoland.NewBalances() for _, balance := range state.Balances { genesisBalances[balance.Address] = balance diff --git a/gno.land/cmd/gnoland/genesis_balances_add_test.go b/gno.land/cmd/gnoland/genesis_balances_add_test.go index 3f97acd1aba..9589bf919cc 100644 --- a/gno.land/cmd/gnoland/genesis_balances_add_test.go +++ b/gno.land/cmd/gnoland/genesis_balances_add_test.go @@ -4,8 +4,6 @@ import ( "bytes" "context" "fmt" - "math" - "strconv" "strings" "testing" @@ -415,135 +413,6 @@ func TestGenesis_Balances_Add(t *testing.T) { }) } -func TestBalances_GetBalancesFromEntries(t *testing.T) { - t.Parallel() - - t.Run("valid balances", func(t *testing.T) { - t.Parallel() - - // Generate dummy keys - dummyKeys := getDummyKeys(t, 2) - amount := std.NewCoins(std.NewCoin("ugnot", 10)) - - balances := make([]string, len(dummyKeys)) - - for index, key := range dummyKeys { - balances[index] = fmt.Sprintf( - "%s=%dugnot", - key.Address().String(), - amount.AmountOf("ugnot"), - ) - } - - balanceMap, err := getBalancesFromEntries(balances) - require.NoError(t, err) - - // Validate the balance map - assert.Len(t, balanceMap, len(dummyKeys)) - for _, key := range dummyKeys { - assert.Equal(t, amount, balanceMap[key.Address()].Amount) - } - }) - - t.Run("malformed balance, invalid format", func(t *testing.T) { - t.Parallel() - - balances := []string{ - "malformed balance", - } - - balanceMap, err := getBalancesFromEntries(balances) - - assert.Nil(t, balanceMap) - assert.ErrorContains(t, err, "malformed entry") - }) - - t.Run("malformed balance, invalid address", func(t *testing.T) { - t.Parallel() - - balances := []string{ - "dummyaddress=10ugnot", - } - - balanceMap, err := getBalancesFromEntries(balances) - - assert.Nil(t, balanceMap) - assert.ErrorContains(t, err, "invalid address") - }) - - t.Run("malformed balance, invalid amount", func(t *testing.T) { - t.Parallel() - - dummyKey := getDummyKey(t) - - balances := []string{ - fmt.Sprintf( - "%s=%sugnot", - dummyKey.Address().String(), - strconv.FormatUint(math.MaxUint64, 10), - ), - } - - balanceMap, err := getBalancesFromEntries(balances) - - assert.Nil(t, balanceMap) - assert.ErrorContains(t, err, "invalid amount") - }) -} - -func TestBalances_GetBalancesFromSheet(t *testing.T) { - t.Parallel() - - t.Run("valid balances", func(t *testing.T) { - t.Parallel() - - // Generate dummy keys - dummyKeys := getDummyKeys(t, 2) - amount := std.NewCoins(std.NewCoin("ugnot", 10)) - - balances := make([]string, len(dummyKeys)) - - for index, key := range dummyKeys { - balances[index] = fmt.Sprintf( - "%s=%dugnot", - key.Address().String(), - amount.AmountOf("ugnot"), - ) - } - - reader := strings.NewReader(strings.Join(balances, "\n")) - balanceMap, err := getBalancesFromSheet(reader) - require.NoError(t, err) - - // Validate the balance map - assert.Len(t, balanceMap, len(dummyKeys)) - for _, key := range dummyKeys { - assert.Equal(t, amount, balanceMap[key.Address()].Amount) - } - }) - - t.Run("malformed balance, invalid amount", func(t *testing.T) { - t.Parallel() - - dummyKey := getDummyKey(t) - - balances := []string{ - fmt.Sprintf( - "%s=%sugnot", - dummyKey.Address().String(), - strconv.FormatUint(math.MaxUint64, 10), - ), - } - - reader := strings.NewReader(strings.Join(balances, "\n")) - - balanceMap, err := getBalancesFromSheet(reader) - - assert.Nil(t, balanceMap) - assert.ErrorContains(t, err, "invalid amount") - }) -} - func TestBalances_GetBalancesFromTransactions(t *testing.T) { t.Parallel() diff --git a/gno.land/cmd/gnoland/genesis_balances_remove.go b/gno.land/cmd/gnoland/genesis_balances_remove.go index a752bbda4fd..5b6e74e0dcf 100644 --- a/gno.land/cmd/gnoland/genesis_balances_remove.go +++ b/gno.land/cmd/gnoland/genesis_balances_remove.go @@ -86,7 +86,7 @@ func execBalancesRemove(cfg *balancesRemoveCfg, io commands.IO) error { delete(genesisBalances, address) // Save the balances - state.Balances = genesisBalances.toList() + state.Balances = genesisBalances.List() genesis.AppState = state // Save the updated genesis diff --git a/gno.land/cmd/gnoland/types.go b/gno.land/cmd/gnoland/types.go index dba39ea8ec1..a48bfaf7b31 100644 --- a/gno.land/cmd/gnoland/types.go +++ b/gno.land/cmd/gnoland/types.go @@ -1,8 +1,6 @@ package main import ( - "github.com/gnolang/gno/gno.land/pkg/gnoland" - "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/std" ) @@ -37,25 +35,3 @@ func (i *txStore) leftMerge(b txStore) error { return nil } - -type accountBalances map[types.Address]gnoland.Balance // address -> balance (ugnot) - -// toList linearizes the account balances map -func (a accountBalances) toList() []gnoland.Balance { - balances := make([]gnoland.Balance, 0, len(a)) - - for _, balance := range a { - balances = append(balances, balance) - } - - return balances -} - -// leftMerge left-merges the two maps -func (a accountBalances) leftMerge(b accountBalances) { - for key, bVal := range b { - if _, present := (a)[key]; !present { - (a)[key] = bVal - } - } -} diff --git a/gno.land/pkg/gnoland/balance.go b/gno.land/pkg/gnoland/balance.go new file mode 100644 index 00000000000..807da4cf41f --- /dev/null +++ b/gno.land/pkg/gnoland/balance.go @@ -0,0 +1,150 @@ +package gnoland + +import ( + "bufio" + "fmt" + "io" + "strings" + + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/std" +) + +type Balance struct { + Address bft.Address + Amount std.Coins +} + +func (b *Balance) Verify() error { + if b.Address.IsZero() { + return ErrBalanceEmptyAddress + } + + if b.Amount.Len() == 0 { + return ErrBalanceEmptyAmount + } + + return nil +} + +func (b *Balance) Parse(entry string) error { + parts := strings.Split(strings.TrimSpace(entry), "=") //
= + if len(parts) != 2 { + return fmt.Errorf("malformed entry: %q", entry) + } + + var err error + + b.Address, err = crypto.AddressFromBech32(parts[0]) + if err != nil { + return fmt.Errorf("invalid address %q: %w", parts[0], err) + } + + b.Amount, err = std.ParseCoins(parts[1]) + if err != nil { + return fmt.Errorf("invalid amount %q: %w", parts[1], err) + } + + return nil +} + +func (b *Balance) UnmarshalAmino(rep string) error { + return b.Parse(rep) +} + +func (b Balance) MarshalAmino() (string, error) { + return b.String(), nil +} + +func (b Balance) String() string { + return fmt.Sprintf("%s=%s", b.Address.String(), b.Amount.String()) +} + +type Balances map[crypto.Address]Balance + +func NewBalances() Balances { + return make(Balances) +} + +func (bs Balances) Set(address crypto.Address, amount std.Coins) { + bs[address] = Balance{ + Address: address, + Amount: amount, + } +} + +func (bs Balances) Get(address crypto.Address) (balance Balance, ok bool) { + balance, ok = bs[address] + return +} + +func (bs Balances) List() []Balance { + list := make([]Balance, 0, len(bs)) + for _, balance := range bs { + list = append(list, balance) + } + return list +} + +// LeftMerge left-merges the two maps +func (bs Balances) LeftMerge(from Balances) { + for key, bVal := range from { + if _, present := (bs)[key]; !present { + (bs)[key] = bVal + } + } +} + +func GetBalancesFromEntries(entries ...string) (Balances, error) { + balances := NewBalances() + return balances, balances.LoadFromEntries(entries...) +} + +// LoadFromEntries extracts the balance entries in the form of
= +func (bs Balances) LoadFromEntries(entries ...string) error { + for _, entry := range entries { + var balance Balance + if err := balance.Parse(entry); err != nil { + return fmt.Errorf("unable to parse balance entry: %w", err) + } + bs[balance.Address] = balance + } + + return nil +} + +func GetBalancesFromSheet(sheet io.Reader) (Balances, error) { + balances := NewBalances() + return balances, balances.LoadFromSheet(sheet) +} + +// LoadFromSheet extracts the balance sheet from the passed in +// balance sheet file, that has the format of
=ugnot +func (bs Balances) LoadFromSheet(sheet io.Reader) error { + // Parse the balances + scanner := bufio.NewScanner(sheet) + + for scanner.Scan() { + entry := scanner.Text() + + // Remove comments + entry = strings.Split(entry, "#")[0] + entry = strings.TrimSpace(entry) + + // Skip empty lines + if entry == "" { + continue + } + + if err := bs.LoadFromEntries(entry); err != nil { + return fmt.Errorf("unable to load entries: %w", err) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error encountered while scanning, %w", err) + } + + return nil +} diff --git a/gno.land/pkg/gnoland/balance_test.go b/gno.land/pkg/gnoland/balance_test.go new file mode 100644 index 00000000000..59dffcc4333 --- /dev/null +++ b/gno.land/pkg/gnoland/balance_test.go @@ -0,0 +1,274 @@ +package gnoland + +import ( + "fmt" + "math" + "strconv" + "strings" + "testing" + + "github.com/gnolang/gno/tm2/pkg/amino" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/crypto/bip39" + "github.com/gnolang/gno/tm2/pkg/crypto/hd" + "github.com/gnolang/gno/tm2/pkg/crypto/keys/client" + "github.com/gnolang/gno/tm2/pkg/crypto/secp256k1" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBalance_Verify(t *testing.T) { + validAddress := crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + emptyAmount := std.Coins{} + nonEmptyAmount := std.NewCoins(std.NewCoin("test", 100)) + + tests := []struct { + name string + balance Balance + expectErr bool + }{ + {"empty amount", Balance{Address: validAddress, Amount: emptyAmount}, true}, + {"empty address", Balance{Address: bft.Address{}, Amount: nonEmptyAmount}, true}, + {"valid balance", Balance{Address: validAddress, Amount: nonEmptyAmount}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.balance.Verify() + if tc.expectErr { + assert.Error(t, err, fmt.Sprintf("TestVerifyBalance: %s", tc.name)) + } else { + assert.NoError(t, err, fmt.Sprintf("TestVerifyBalance: %s", tc.name)) + } + }) + } +} + +func TestBalance_Parse(t *testing.T) { + validAddress := crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + validBalance := Balance{Address: validAddress, Amount: std.NewCoins(std.NewCoin("test", 100))} + + tests := []struct { + name string + entry string + expected Balance + expectErr bool + }{ + {"valid entry", "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5=100test", validBalance, false}, + {"invalid address", "invalid=100test", Balance{}, true}, + {"incomplete entry", "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", Balance{}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + balance := Balance{} + err := balance.Parse(tc.entry) + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, balance) + } + }) + } +} + +func TestBalance_AminoUnmarshalJSON(t *testing.T) { + expected := Balance{ + Address: crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + Amount: std.MustParseCoins("100ugnot"), + } + value := fmt.Sprintf("[%q]", expected.String()) + + var balances []Balance + err := amino.UnmarshalJSON([]byte(value), &balances) + require.NoError(t, err) + require.Len(t, balances, 1, "there should be one balance after unmarshaling") + + balance := balances[0] + require.Equal(t, expected.Address, balance.Address) + require.True(t, expected.Amount.IsEqual(balance.Amount)) +} + +func TestBalance_AminoMarshalJSON(t *testing.T) { + expected := Balance{ + Address: crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + Amount: std.MustParseCoins("100ugnot"), + } + expectedJSON := fmt.Sprintf("[%q]", expected.String()) + + balancesJSON, err := amino.MarshalJSON([]Balance{expected}) + require.NoError(t, err) + require.JSONEq(t, expectedJSON, string(balancesJSON)) +} + +func TestBalances_GetBalancesFromEntries(t *testing.T) { + t.Parallel() + + t.Run("valid balances", func(t *testing.T) { + t.Parallel() + + // Generate dummy keys + dummyKeys := getDummyKeys(t, 2) + amount := std.NewCoins(std.NewCoin("ugnot", 10)) + + entries := make([]string, len(dummyKeys)) + + for index, key := range dummyKeys { + entries[index] = fmt.Sprintf( + "%s=%dugnot", + key.Address().String(), + amount.AmountOf("ugnot"), + ) + } + + balanceMap, err := GetBalancesFromEntries(entries...) + require.NoError(t, err) + + // Validate the balance map + assert.Len(t, balanceMap, len(dummyKeys)) + for _, key := range dummyKeys { + assert.Equal(t, amount, balanceMap[key.Address()].Amount) + } + }) + + t.Run("malformed balance, invalid format", func(t *testing.T) { + t.Parallel() + + entries := []string{ + "malformed balance", + } + + balanceMap, err := GetBalancesFromEntries(entries...) + assert.Len(t, balanceMap, 0) + assert.Contains(t, err.Error(), "malformed entry") + }) + + t.Run("malformed balance, invalid address", func(t *testing.T) { + t.Parallel() + + balances := []string{ + "dummyaddress=10ugnot", + } + + balanceMap, err := GetBalancesFromEntries(balances...) + assert.Len(t, balanceMap, 0) + assert.ErrorContains(t, err, "invalid address") + }) + + t.Run("malformed balance, invalid amount", func(t *testing.T) { + t.Parallel() + + dummyKey := getDummyKey(t) + + balances := []string{ + fmt.Sprintf( + "%s=%sugnot", + dummyKey.Address().String(), + strconv.FormatUint(math.MaxUint64, 10), + ), + } + + balanceMap, err := GetBalancesFromEntries(balances...) + assert.Len(t, balanceMap, 0) + assert.ErrorContains(t, err, "invalid amount") + }) +} + +func TestBalances_GetBalancesFromSheet(t *testing.T) { + t.Parallel() + + t.Run("valid balances", func(t *testing.T) { + t.Parallel() + + // Generate dummy keys + dummyKeys := getDummyKeys(t, 2) + amount := std.NewCoins(std.NewCoin("ugnot", 10)) + + balances := make([]string, len(dummyKeys)) + + for index, key := range dummyKeys { + balances[index] = fmt.Sprintf( + "%s=%dugnot", + key.Address().String(), + amount.AmountOf("ugnot"), + ) + } + + reader := strings.NewReader(strings.Join(balances, "\n")) + balanceMap, err := GetBalancesFromSheet(reader) + require.NoError(t, err) + + // Validate the balance map + assert.Len(t, balanceMap, len(dummyKeys)) + for _, key := range dummyKeys { + assert.Equal(t, amount, balanceMap[key.Address()].Amount) + } + }) + + t.Run("malformed balance, invalid amount", func(t *testing.T) { + t.Parallel() + + dummyKey := getDummyKey(t) + + balances := []string{ + fmt.Sprintf( + "%s=%sugnot", + dummyKey.Address().String(), + strconv.FormatUint(math.MaxUint64, 10), + ), + } + + reader := strings.NewReader(strings.Join(balances, "\n")) + + balanceMap, err := GetBalancesFromSheet(reader) + + assert.Len(t, balanceMap, 0) + assert.Contains(t, err.Error(), "invalid amount") + }) +} + +// XXX: this function should probably be exposed somewhere as it's duplicate of +// cmd/genesis/... + +// getDummyKey generates a random public key, +// and returns the key info +func getDummyKey(t *testing.T) crypto.PubKey { + t.Helper() + + mnemonic, err := client.GenerateMnemonic(256) + require.NoError(t, err) + + seed := bip39.NewSeed(mnemonic, "") + + return generateKeyFromSeed(seed, 0).PubKey() +} + +// getDummyKeys generates random keys for testing +func getDummyKeys(t *testing.T, count int) []crypto.PubKey { + t.Helper() + + dummyKeys := make([]crypto.PubKey, count) + + for i := 0; i < count; i++ { + dummyKeys[i] = getDummyKey(t) + } + + return dummyKeys +} + +// generateKeyFromSeed generates a private key from +// the provided seed and index +func generateKeyFromSeed(seed []byte, index uint32) crypto.PrivKey { + pathParams := hd.NewFundraiserParams(0, crypto.CoinType, index) + + masterPriv, ch := hd.ComputeMastersFromSeed(seed) + + //nolint:errcheck // This derivation can never error out, since the path params + // are always going to be valid + derivedPriv, _ := hd.DerivePrivateKeyForPath(masterPriv, ch, pathParams.String()) + + return secp256k1.PrivKeySecp256k1(derivedPriv) +} diff --git a/gno.land/pkg/gnoland/types.go b/gno.land/pkg/gnoland/types.go index 5d68064c9c5..016f3279dbd 100644 --- a/gno.land/pkg/gnoland/types.go +++ b/gno.land/pkg/gnoland/types.go @@ -2,11 +2,7 @@ package gnoland import ( "errors" - "fmt" - "strings" - bft "github.com/gnolang/gno/tm2/pkg/bft/types" - "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/std" ) @@ -27,53 +23,3 @@ type GnoGenesisState struct { Balances []Balance `json:"balances"` Txs []std.Tx `json:"txs"` } - -type Balance struct { - Address bft.Address - Amount std.Coins -} - -func (b *Balance) Verify() error { - if b.Address.IsZero() { - return ErrBalanceEmptyAddress - } - - if b.Amount.Len() == 0 { - return ErrBalanceEmptyAmount - } - - return nil -} - -func (b *Balance) Parse(entry string) error { - parts := strings.Split(strings.TrimSpace(entry), "=") //
= - if len(parts) != 2 { - return fmt.Errorf("malformed entry: %q", entry) - } - - var err error - - b.Address, err = crypto.AddressFromBech32(parts[0]) - if err != nil { - return fmt.Errorf("invalid address %q: %w", parts[0], err) - } - - b.Amount, err = std.ParseCoins(parts[1]) - if err != nil { - return fmt.Errorf("invalid amount %q: %w", parts[1], err) - } - - return nil -} - -func (b *Balance) UnmarshalAmino(rep string) error { - return b.Parse(rep) -} - -func (b Balance) MarshalAmino() (string, error) { - return b.String(), nil -} - -func (b Balance) String() string { - return fmt.Sprintf("%s=%s", b.Address.String(), b.Amount.String()) -} diff --git a/gno.land/pkg/gnoland/types_test.go b/gno.land/pkg/gnoland/types_test.go deleted file mode 100644 index 3b6d18a29ea..00000000000 --- a/gno.land/pkg/gnoland/types_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package gnoland - -import ( - "fmt" - "testing" - - "github.com/gnolang/gno/tm2/pkg/amino" - bft "github.com/gnolang/gno/tm2/pkg/bft/types" - "github.com/gnolang/gno/tm2/pkg/crypto" - "github.com/gnolang/gno/tm2/pkg/std" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBalance_Verify(t *testing.T) { - validAddress := crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") - emptyAmount := std.Coins{} - nonEmptyAmount := std.NewCoins(std.NewCoin("test", 100)) - - tests := []struct { - name string - balance Balance - expectErr bool - }{ - {"empty amount", Balance{Address: validAddress, Amount: emptyAmount}, true}, - {"empty address", Balance{Address: bft.Address{}, Amount: nonEmptyAmount}, true}, - {"valid balance", Balance{Address: validAddress, Amount: nonEmptyAmount}, false}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := tc.balance.Verify() - if tc.expectErr { - assert.Error(t, err, fmt.Sprintf("TestVerifyBalance: %s", tc.name)) - } else { - assert.NoError(t, err, fmt.Sprintf("TestVerifyBalance: %s", tc.name)) - } - }) - } -} - -func TestBalance_Parse(t *testing.T) { - validAddress := crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") - validBalance := Balance{Address: validAddress, Amount: std.NewCoins(std.NewCoin("test", 100))} - - tests := []struct { - name string - entry string - expected Balance - expectErr bool - }{ - {"valid entry", "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5=100test", validBalance, false}, - {"invalid address", "invalid=100test", Balance{}, true}, - {"incomplete entry", "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", Balance{}, true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - balance := Balance{} - err := balance.Parse(tc.entry) - if tc.expectErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expected, balance) - } - }) - } -} - -func TestBalance_AminoUnmarshalJSON(t *testing.T) { - expected := Balance{ - Address: crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), - Amount: std.MustParseCoins("100ugnot"), - } - value := fmt.Sprintf("[%q]", expected.String()) - - var balances []Balance - err := amino.UnmarshalJSON([]byte(value), &balances) - require.NoError(t, err) - require.Len(t, balances, 1, "there should be one balance after unmarshaling") - - balance := balances[0] - require.Equal(t, expected.Address, balance.Address) - require.True(t, expected.Amount.IsEqual(balance.Amount)) -} - -func TestBalance_AminoMarshalJSON(t *testing.T) { - expected := Balance{ - Address: crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), - Amount: std.MustParseCoins("100ugnot"), - } - expectedJSON := fmt.Sprintf("[%q]", expected.String()) - - balancesJSON, err := amino.MarshalJSON([]Balance{expected}) - require.NoError(t, err) - require.JSONEq(t, expectedJSON, string(balancesJSON)) -} diff --git a/tm2/pkg/crypto/keys/client/export.go b/tm2/pkg/crypto/keys/client/export.go index bb368342ef8..b80942734f6 100644 --- a/tm2/pkg/crypto/keys/client/export.go +++ b/tm2/pkg/crypto/keys/client/export.go @@ -99,6 +99,13 @@ func execExport(cfg *ExportCfg, io commands.IO) error { cfg.NameOrBech32, decryptPassword, ) + + privk, err := kb.ExportPrivateKeyObject(cfg.NameOrBech32, decryptPassword) + if err != nil { + panic(err) + } + + fmt.Printf("privk:\n%x\n", privk.Bytes()) } else { // Get the armor encrypt password encryptPassword, err := io.GetCheckPassword(