diff --git a/gno.land/cmd/genesis/main.go b/gno.land/cmd/genesis/main.go index 89b2d81d87b..f819231fd14 100644 --- a/gno.land/cmd/genesis/main.go +++ b/gno.land/cmd/genesis/main.go @@ -33,6 +33,7 @@ func newRootCmd(io *commands.IO) *commands.Command { newGenerateCmd(io), newValidatorCmd(io), newVerifyCmd(io), + newTxsCmd(io), ) return cmd diff --git a/gno.land/cmd/genesis/txs.go b/gno.land/cmd/genesis/txs.go new file mode 100644 index 00000000000..567ed7b9ea0 --- /dev/null +++ b/gno.land/cmd/genesis/txs.go @@ -0,0 +1,43 @@ +package main + +import ( + "flag" + + "github.com/gnolang/gno/tm2/pkg/commands" +) + +type txsCfg struct { + genesisPath string +} + +// newTxsCmd creates the genesis txs subcommand +func newTxsCmd(io *commands.IO) *commands.Command { + cfg := &txsCfg{} + + cmd := commands.NewCommand( + commands.Metadata{ + Name: "txs", + ShortUsage: "txs [flags]", + ShortHelp: "Manages the initial genesis transactions", + LongHelp: "Manages genesis transactions through input files", + }, + cfg, + commands.HelpExec, + ) + + cmd.AddSubCommands( + newTxsAddCmd(cfg, io), + newTxsRemoveCmd(cfg, io), + ) + + return cmd +} + +func (c *txsCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.genesisPath, + "genesis-path", + "./genesis.json", + "the path to the genesis.json", + ) +} diff --git a/gno.land/cmd/genesis/txs_add.go b/gno.land/cmd/genesis/txs_add.go new file mode 100644 index 00000000000..fa3833aff98 --- /dev/null +++ b/gno.land/cmd/genesis/txs_add.go @@ -0,0 +1,146 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/std" +) + +var ( + errInvalidTxsFile = errors.New("unable to open transactions file") + errTxsParsingAborted = errors.New("transaction parsing aborted") +) + +type txsAddCfg struct { + rootCfg *txsCfg + + parseExport string +} + +// newTxsAddCmd creates the genesis txs add subcommand +func newTxsAddCmd(txsCfg *txsCfg, io *commands.IO) *commands.Command { + cfg := &txsAddCfg{ + rootCfg: txsCfg, + } + + return commands.NewCommand( + commands.Metadata{ + Name: "add", + ShortUsage: "txs add [flags]", + ShortHelp: "Imports transactions into the genesis.json", + LongHelp: "Imports the transactions from a tx-archive backup to the genesis.json", + }, + cfg, + func(ctx context.Context, _ []string) error { + return execTxsAdd(ctx, cfg, io) + }, + ) +} + +func (c *txsAddCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.parseExport, + "parse-export", + "", + "the path to the transactions export containing a list of transactions", + ) +} + +func execTxsAdd(ctx context.Context, cfg *txsAddCfg, io *commands.IO) error { + // Load the genesis + genesis, loadErr := types.GenesisDocFromFile(cfg.rootCfg.genesisPath) + if loadErr != nil { + return fmt.Errorf("unable to load genesis, %w", loadErr) + } + + // Open the transactions file + file, loadErr := os.Open(cfg.parseExport) + if loadErr != nil { + return fmt.Errorf("%w, %w", errInvalidTxsFile, loadErr) + } + + txs, err := getTransactionsFromFile(ctx, file) + if err != nil { + return fmt.Errorf("unable to read file, %w", err) + } + + // Initialize the app state if it's not present + if genesis.AppState == nil { + genesis.AppState = gnoland.GnoGenesisState{} + } + + state := genesis.AppState.(gnoland.GnoGenesisState) + + // Left merge the transactions + fileTxStore := txStore(txs) + genesisTxStore := txStore(state.Txs) + + // The genesis transactions have preference with the order + // in the genesis.json + if err := genesisTxStore.leftMerge(fileTxStore); err != nil { + return err + } + + // Save the state + state.Txs = genesisTxStore + genesis.AppState = state + + // Save the updated genesis + if err := genesis.SaveAs(cfg.rootCfg.genesisPath); err != nil { + return fmt.Errorf("unable to save genesis.json, %w", err) + } + + io.Printfln( + "Saved %d transactions to genesis.json", + len(txs), + ) + + return nil +} + +// getTransactionsFromFile fetches the transactions from the +// specified reader +func getTransactionsFromFile(ctx context.Context, reader io.Reader) ([]std.Tx, error) { + txs := make([]std.Tx, 0) + + scanner := bufio.NewScanner(reader) + + for scanner.Scan() { + select { + case <-ctx.Done(): + return nil, errTxsParsingAborted + default: + // Parse the amino JSON + var tx std.Tx + + if err := amino.UnmarshalJSON(scanner.Bytes(), &tx); err != nil { + return nil, fmt.Errorf( + "unable to unmarshal amino JSON, %w", + err, + ) + } + + txs = append(txs, tx) + } + } + + // Check for scanning errors + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf( + "error encountered while reading file, %w", + err, + ) + } + + return txs, nil +} diff --git a/gno.land/cmd/genesis/txs_add_test.go b/gno.land/cmd/genesis/txs_add_test.go new file mode 100644 index 00000000000..5e059ca6224 --- /dev/null +++ b/gno.land/cmd/genesis/txs_add_test.go @@ -0,0 +1,247 @@ +package main + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/sdk/bank" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/gnolang/gno/tm2/pkg/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generateDummyTxs generates dummy transactions +func generateDummyTxs(t *testing.T, count int) []std.Tx { + t.Helper() + + txs := make([]std.Tx, count) + + for i := 0; i < count; i++ { + txs[i] = std.Tx{ + Msgs: []std.Msg{ + bank.MsgSend{ + FromAddress: crypto.Address{byte(i)}, + ToAddress: crypto.Address{byte((i + 1) % count)}, + Amount: std.NewCoins(std.NewCoin("ugnot", 1)), + }, + }, + Fee: std.Fee{ + GasWanted: 1, + GasFee: std.NewCoin("ugnot", 1000000), + }, + Memo: fmt.Sprintf("tx %d", i), + } + } + + return txs +} + +// encodeDummyTxs encodes the transactions into amino JSON +func encodeDummyTxs(t *testing.T, txs []std.Tx) []string { + t.Helper() + + encodedTxs := make([]string, 0, len(txs)) + + for _, tx := range txs { + encodedTx, err := amino.MarshalJSON(tx) + if err != nil { + t.Fatalf("unable to marshal tx, %v", err) + } + + encodedTxs = append(encodedTxs, string(encodedTx)) + } + + return encodedTxs +} + +func TestGenesis_Txs_Add(t *testing.T) { + t.Parallel() + + t.Run("invalid genesis file", func(t *testing.T) { + t.Parallel() + + // Create the command + cmd := newRootCmd(commands.NewTestIO()) + args := []string{ + "txs", + "add", + "--genesis-path", + "dummy-path", + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + assert.ErrorContains(t, cmdErr, "unable to load genesis") + }) + + t.Run("invalid txs file", func(t *testing.T) { + t.Parallel() + + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + genesis := getDefaultGenesis() + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + + // Create the command + cmd := newRootCmd(commands.NewTestIO()) + args := []string{ + "txs", + "add", + "--genesis-path", + tempGenesis.Name(), + "--parse-export", + "dummy-tx-file", + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + assert.ErrorContains(t, cmdErr, errInvalidTxsFile.Error()) + }) + + t.Run("malformed txs file", func(t *testing.T) { + t.Parallel() + + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + genesis := getDefaultGenesis() + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + + // Create the command + cmd := newRootCmd(commands.NewTestIO()) + args := []string{ + "txs", + "add", + "--genesis-path", + tempGenesis.Name(), + "--parse-export", + tempGenesis.Name(), // invalid txs file + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + assert.ErrorContains(t, cmdErr, "unable to read file") + }) + + t.Run("valid txs file", func(t *testing.T) { + t.Parallel() + + // Generate dummy txs + txs := generateDummyTxs(t, 10) + + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + genesis := getDefaultGenesis() + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + + // Prepare the transactions file + txsFile, txsCleanup := testutils.NewTestFile(t) + t.Cleanup(txsCleanup) + + _, err := txsFile.WriteString( + strings.Join( + encodeDummyTxs(t, txs), + "\n", + ), + ) + require.NoError(t, err) + + // Create the command + cmd := newRootCmd(commands.NewTestIO()) + args := []string{ + "txs", + "add", + "--genesis-path", + tempGenesis.Name(), + "--parse-export", + txsFile.Name(), + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + require.NoError(t, cmdErr) + + // Validate the transactions were written down + updatedGenesis, err := types.GenesisDocFromFile(tempGenesis.Name()) + require.NoError(t, err) + require.NotNil(t, updatedGenesis.AppState) + + // Fetch the state + state := updatedGenesis.AppState.(gnoland.GnoGenesisState) + + assert.Len(t, state.Txs, len(txs)) + + for index, tx := range state.Txs { + assert.Equal(t, txs[index], tx) + } + }) + + t.Run("existing genesis txs", func(t *testing.T) { + t.Parallel() + + // Generate dummy txs + txs := generateDummyTxs(t, 10) + + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + genesis := getDefaultGenesis() + genesisState := gnoland.GnoGenesisState{ + Txs: txs[0 : len(txs)/2], + } + + genesis.AppState = genesisState + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + + // Prepare the transactions file + txsFile, txsCleanup := testutils.NewTestFile(t) + t.Cleanup(txsCleanup) + + _, err := txsFile.WriteString( + strings.Join( + encodeDummyTxs(t, txs), + "\n", + ), + ) + require.NoError(t, err) + + // Create the command + cmd := newRootCmd(commands.NewTestIO()) + args := []string{ + "txs", + "add", + "--genesis-path", + tempGenesis.Name(), + "--parse-export", + txsFile.Name(), + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + require.NoError(t, cmdErr) + + // Validate the transactions were written down + updatedGenesis, err := types.GenesisDocFromFile(tempGenesis.Name()) + require.NoError(t, err) + require.NotNil(t, updatedGenesis.AppState) + + // Fetch the state + state := updatedGenesis.AppState.(gnoland.GnoGenesisState) + + assert.Len(t, state.Txs, len(txs)) + + for index, tx := range state.Txs { + assert.Equal(t, txs[index], tx) + } + }) +} diff --git a/gno.land/cmd/genesis/txs_remove.go b/gno.land/cmd/genesis/txs_remove.go new file mode 100644 index 00000000000..b11abcf4e77 --- /dev/null +++ b/gno.land/cmd/genesis/txs_remove.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "strings" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" +) + +var ( + errAppStateNotSet = errors.New("genesis app state not set") + errTxNotFound = errors.New("transaction not present in genesis.json") +) + +type txsRemoveCfg struct { + rootCfg *txsCfg + + hash string +} + +// newTxsRemoveCmd creates the genesis txs remove subcommand +func newTxsRemoveCmd(txsCfg *txsCfg, io *commands.IO) *commands.Command { + cfg := &txsRemoveCfg{ + rootCfg: txsCfg, + } + + return commands.NewCommand( + commands.Metadata{ + Name: "remove", + ShortUsage: "txs remove [flags]", + ShortHelp: "Removes the transaction from the genesis.json", + LongHelp: "Removes the transaction using the transaction hash", + }, + cfg, + func(_ context.Context, _ []string) error { + return execTxsRemove(cfg, io) + }, + ) +} + +func (c *txsRemoveCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.hash, + "hash", + "", + "the transaction hash (hex format)", + ) +} + +func execTxsRemove(cfg *txsRemoveCfg, io *commands.IO) error { + // Load the genesis + genesis, loadErr := types.GenesisDocFromFile(cfg.rootCfg.genesisPath) + if loadErr != nil { + return fmt.Errorf("unable to load genesis, %w", loadErr) + } + + // Check if the genesis state is set at all + if genesis.AppState == nil { + return errAppStateNotSet + } + + var ( + state = genesis.AppState.(gnoland.GnoGenesisState) + index = -1 + ) + + for indx, tx := range state.Txs { + // Find the hash of the transaction + hash, err := getTxHash(tx) + if err != nil { + return fmt.Errorf("unable to generate tx hash, %w", err) + } + + // Check if the hashes match + if strings.ToLower(hash) == strings.ToLower(cfg.hash) { + index = indx + + break + } + } + + if index < 0 { + return errTxNotFound + } + + state.Txs = append(state.Txs[:index], state.Txs[index+1:]...) + genesis.AppState = state + + // Save the updated genesis + if err := genesis.SaveAs(cfg.rootCfg.genesisPath); err != nil { + return fmt.Errorf("unable to save genesis.json, %w", err) + } + + io.Printfln( + "Transaction %s removed from genesis.json", + cfg.hash, + ) + + return nil +} diff --git a/gno.land/cmd/genesis/txs_remove_test.go b/gno.land/cmd/genesis/txs_remove_test.go new file mode 100644 index 00000000000..8f5b4392ef3 --- /dev/null +++ b/gno.land/cmd/genesis/txs_remove_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "testing" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenesis_Txs_Remove(t *testing.T) { + t.Parallel() + + t.Run("invalid genesis file", func(t *testing.T) { + t.Parallel() + + // Create the command + cmd := newRootCmd(commands.NewTestIO()) + args := []string{ + "txs", + "remove", + "--genesis-path", + "dummy-path", + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + assert.ErrorContains(t, cmdErr, "unable to load genesis") + }) + + t.Run("invalid genesis app state", func(t *testing.T) { + t.Parallel() + + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + genesis := getDefaultGenesis() + genesis.AppState = nil // no app state + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + + // Create the command + cmd := newRootCmd(commands.NewTestIO()) + args := []string{ + "txs", + "remove", + "--genesis-path", + tempGenesis.Name(), + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + assert.ErrorContains(t, cmdErr, errAppStateNotSet.Error()) + }) + + t.Run("transaction not found", func(t *testing.T) { + t.Parallel() + + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + // Generate dummy txs + txs := generateDummyTxs(t, 10) + + genesis := getDefaultGenesis() + genesis.AppState = gnoland.GnoGenesisState{ + Txs: txs, + } + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + + // Create the command + cmd := newRootCmd(commands.NewTestIO()) + args := []string{ + "txs", + "remove", + "--genesis-path", + tempGenesis.Name(), + "--hash", + "dummy hash", + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + assert.ErrorContains(t, cmdErr, errTxNotFound.Error()) + }) + + t.Run("transaction removed", func(t *testing.T) { + t.Parallel() + + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + // Generate dummy txs + txs := generateDummyTxs(t, 10) + + genesis := getDefaultGenesis() + genesis.AppState = gnoland.GnoGenesisState{ + Txs: txs, + } + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + + txHash, err := getTxHash(txs[0]) + require.NoError(t, err) + + // Create the command + cmd := newRootCmd(commands.NewTestIO()) + args := []string{ + "txs", + "remove", + "--genesis-path", + tempGenesis.Name(), + "--hash", + txHash, + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + require.NoError(t, cmdErr) + + // Validate the transaction was removed + updatedGenesis, err := types.GenesisDocFromFile(tempGenesis.Name()) + require.NoError(t, err) + require.NotNil(t, updatedGenesis.AppState) + + // Fetch the state + state := updatedGenesis.AppState.(gnoland.GnoGenesisState) + + assert.Len(t, state.Txs, len(txs)-1) + + for _, tx := range state.Txs { + genesisTxHash, err := getTxHash(tx) + require.NoError(t, err) + + assert.NotEqual(t, txHash, genesisTxHash) + } + }) +} diff --git a/gno.land/cmd/genesis/types.go b/gno.land/cmd/genesis/types.go new file mode 100644 index 00000000000..a48bfaf7b31 --- /dev/null +++ b/gno.land/cmd/genesis/types.go @@ -0,0 +1,37 @@ +package main + +import ( + "github.com/gnolang/gno/tm2/pkg/std" +) + +// txStore is a wrapper for TM2 transactions +type txStore []std.Tx + +// leftMerge merges the two tx stores, with +// preference to the left +func (i *txStore) leftMerge(b txStore) error { + // Build out the tx hash map + txHashMap := make(map[string]struct{}, len(*i)) + + for _, tx := range *i { + txHash, err := getTxHash(tx) + if err != nil { + return err + } + + txHashMap[txHash] = struct{}{} + } + + for _, tx := range b { + txHash, err := getTxHash(tx) + if err != nil { + return err + } + + if _, exists := txHashMap[txHash]; !exists { + *i = append(*i, tx) + } + } + + return nil +} diff --git a/gno.land/cmd/genesis/utils.go b/gno.land/cmd/genesis/utils.go new file mode 100644 index 00000000000..bc67656af68 --- /dev/null +++ b/gno.land/cmd/genesis/utils.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + + "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/std" +) + +func getTxHash(tx std.Tx) (string, error) { + encodedTx, err := amino.Marshal(tx) + if err != nil { + return "", fmt.Errorf("unable to marshal transaction, %w", err) + } + + txHash := types.Tx(encodedTx).Hash() + + return fmt.Sprintf("%X", txHash), nil +}