From 4d08a7f4a433fb2cb36a4bf5a060daf29b4393c9 Mon Sep 17 00:00:00 2001 From: Bao Pham <145053932+bao1029p@users.noreply.github.com> Date: Tue, 5 Dec 2023 23:35:46 +0700 Subject: [PATCH] feat: Use file input in payForBlobs CLI to allow to submit multiple blobs (#2856) Close [#2434](https://github.com/celestiaorg/celestia-app/issues/2434) ## Changes made - Add blobJson proto type define how user can submit blob in file - Add file path as argument input for `payForBlob` command - Add `parseSubmitBlobs` to parse content from the file to array of defined `blobJSON`. Each blobJSON contain a namespaceID and blob in hex encoded format ( can modify what the user can put in the file later on, this need further conversation on this ) - Modifed the `broadcastPFB` func to accept multiple blobs instead ## Testing test the command with single-node script running and got this result ( thanks @sontrinh16 for providing the test result ) ![image](https://github.com/celestiaorg/celestia-app/assets/145053932/720d6ccd-3bcc-4ac3-999d-90cc844cfad4) using the test_blob.json file contain: `{ "Blobs": [ { "namespaceId": "0x00010203040506070809", "blob": "0x48656c6c6f2c20576f726c6421" }, { "namespaceId": "0x00010203040506070809", "blob": "0x48656c6c6f2c20576f726c6421" } ] }` ## Checklist - [ ] New and updated code has appropriate documentation - [x] New and updated code has new and/or updated testing - [x] Required CI checks are passing - [x] Visual proof for any user facing features like CLI or documentation updates - [x] Linked issues closed with keywords --------- Co-authored-by: sontrinh16 Co-authored-by: sontrinh16 <48055119+sontrinh16@users.noreply.github.com> Co-authored-by: Rootul P --- x/blob/client/cli/payforblob.go | 128 +++++++++++++++++---- x/blob/client/cli/util.go | 32 ++++++ x/blob/client/testutil/integration_test.go | 74 +++++++++++- 3 files changed, 207 insertions(+), 27 deletions(-) create mode 100644 x/blob/client/cli/util.go diff --git a/x/blob/client/cli/payforblob.go b/x/blob/client/cli/payforblob.go index 52ebff4cf6..1dbabb541b 100644 --- a/x/blob/client/cli/payforblob.go +++ b/x/blob/client/cli/payforblob.go @@ -4,8 +4,10 @@ import ( "bufio" "encoding/hex" "encoding/json" + "errors" "fmt" "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -28,69 +30,147 @@ const ( // FlagNamespaceVersion allows the user to override the namespace version when // submitting a PayForBlob. FlagNamespaceVersion = "namespace-version" + + // FlagFileInput allows the user to provide file path to the json file + // for submitting multiple blobs. + FlagFileInput = "input-file" ) func CmdPayForBlob() *cobra.Command { cmd := &cobra.Command{ - Use: "PayForBlobs namespaceID blob", + Use: "PayForBlobs [namespaceID blob]", // This example command can be run in a new terminal after running single-node.sh Example: "celestia-appd tx blob PayForBlobs 0x00010203040506070809 0x48656c6c6f2c20576f726c6421 \\\n" + "\t--chain-id private \\\n" + "\t--from validator \\\n" + "\t--keyring-backend test \\\n" + "\t--fees 21000utia \\\n" + - "\t--yes", - Short: "Pay for a data blob to be published to Celestia.", - Long: "Pay for a data blob to be published to Celestia.\n" + - "namespaceID is the user-specifiable portion of a version 0 namespace. It must be a hex encoded string of 10 bytes.\n" + - "blob must be a hex encoded string of any length.\n" + - // TODO: allow for more than one blob to be sumbmitted via the CLI - "This command currently only supports a single blob per invocation.\n", + "\t--yes \n\n" + + "celestia-appd tx blob PayForBlobs --input-file path/to/blobs.json \\\n" + + "\t--chain-id private \\\n" + + "\t--from validator \\\n" + + "\t--keyring-backend test \\\n" + + "\t--fees 21000utia \\\n" + + "\t--yes \n", + Short: "Pay for a data blob(s) to be published to Celestia.", + Long: "Pay for a data blob(s) to be published to Celestia.\n" + + "User can use namespaceID and blob as argument for single blob submission \n" + + "or use --input-file flag with the path to a json file for multiple blobs submission, \n" + + `where the json file contains: + + { + "Blobs": [ + { + "namespaceID": "0x00010203040506070809", + "blob": "0x48656c6c6f2c20576f726c6421" + }, + { + "namespaceID": "0x00010203040506070809", + "blob": "0x48656c6c6f2c20576f726c6421" + } + ] + } + + namespaceID is the user-specifiable portion of a version 0 namespace. It must be a hex encoded string of 10 bytes.\n + blob must be a hex encoded string of any length.\n + `, Aliases: []string{"PayForBlob"}, Args: func(cmd *cobra.Command, args []string) error { + path, err := cmd.Flags().GetString(FlagFileInput) + if err != nil { + return err + } + + // If there is a file path input we'll check for the file extension + if path != "" { + if filepath.Ext(path) != ".json" { + return fmt.Errorf("invalid file extension, require json got %s", filepath.Ext(path)) + } + + return nil + } + if len(args) < 2 { - return fmt.Errorf("PayForBlobs requires two arguments: namespaceID and blob") + return errors.New("PayForBlobs requires two arguments: namespaceID and blob") } + return nil }, RunE: func(cmd *cobra.Command, args []string) error { - arg0 := strings.TrimPrefix(args[0], "0x") - namespaceID, err := hex.DecodeString(arg0) - if err != nil { - return fmt.Errorf("failed to decode hex namespace ID: %w", err) - } namespaceVersion, err := cmd.Flags().GetUint8(FlagNamespaceVersion) if err != nil { return err } - namespace, err := getNamespace(namespaceID, namespaceVersion) + + shareVersion, err := cmd.Flags().GetUint8(FlagShareVersion) if err != nil { return err } - arg1 := strings.TrimPrefix(args[1], "0x") - rawblob, err := hex.DecodeString(arg1) + path, err := cmd.Flags().GetString(FlagFileInput) if err != nil { - return fmt.Errorf("failure to decode hex blob: %w", err) + return err } - shareVersion, _ := cmd.Flags().GetUint8(FlagShareVersion) - blob, err := types.NewBlob(namespace, rawblob, shareVersion) + // In case of no file input, get the namespaceID and blob from the arguments + if path == "" { + blob, err := getBlobFromArguments(args[0], args[1], namespaceVersion, shareVersion) + if err != nil { + return err + } + + return broadcastPFB(cmd, blob) + } + + paresdBlobs, err := parseSubmitBlobs(path) if err != nil { return err } - return broadcastPFB(cmd, blob) + var blobs []*blob.Blob + for _, paresdBlob := range paresdBlobs { + blob, err := getBlobFromArguments(paresdBlob.NamespaceID, paresdBlob.Blob, namespaceVersion, shareVersion) + if err != nil { + return err + } + blobs = append(blobs, blob) + } + + return broadcastPFB(cmd, blobs...) }, } flags.AddTxFlagsToCmd(cmd) cmd.PersistentFlags().Uint8(FlagNamespaceVersion, 0, "Specify the namespace version (default 0)") cmd.PersistentFlags().Uint8(FlagShareVersion, 0, "Specify the share version (default 0)") + cmd.PersistentFlags().String(FlagFileInput, "", "Specify the file input") _ = cmd.MarkFlagRequired(flags.FlagFrom) return cmd } +func getBlobFromArguments(namespaceIDArg, blobArg string, namespaceVersion, shareVersion uint8) (*blob.Blob, error) { + namespaceID, err := hex.DecodeString(strings.TrimPrefix(namespaceIDArg, "0x")) + if err != nil { + return nil, fmt.Errorf("failed to decode hex namespace ID: %w", err) + } + namespace, err := getNamespace(namespaceID, namespaceVersion) + if err != nil { + return nil, err + } + hexStr := strings.TrimPrefix(blobArg, "0x") + rawblob, err := hex.DecodeString(hexStr) + if err != nil { + return nil, fmt.Errorf("failure to decode hex blob value %s: %s", hexStr, err.Error()) + } + + blob, err := types.NewBlob(namespace, rawblob, shareVersion) + if err != nil { + return nil, fmt.Errorf("failure to create blob with hex blob value %s: %s", hexStr, err.Error()) + } + + return blob, nil +} + func getNamespace(namespaceID []byte, namespaceVersion uint8) (appns.Namespace, error) { switch namespaceVersion { case appns.NamespaceVersionZero: @@ -108,7 +188,7 @@ func getNamespace(namespaceID []byte, namespaceVersion uint8) (appns.Namespace, // broadcastPFB creates the new PFB message type that will later be broadcast to tendermint nodes // this private func is used in CmdPayForBlob -func broadcastPFB(cmd *cobra.Command, b *blob.Blob) error { +func broadcastPFB(cmd *cobra.Command, b ...*blob.Blob) error { clientCtx, err := client.GetClientTxContext(cmd) if err != nil { return err @@ -116,7 +196,7 @@ func broadcastPFB(cmd *cobra.Command, b *blob.Blob) error { // TODO: allow the user to override the share version via a new flag // See https://github.com/celestiaorg/celestia-app/issues/1041 - pfbMsg, err := types.NewMsgPayForBlobs(clientCtx.FromAddress.String(), b) + pfbMsg, err := types.NewMsgPayForBlobs(clientCtx.FromAddress.String(), b...) if err != nil { return err } @@ -131,7 +211,7 @@ func broadcastPFB(cmd *cobra.Command, b *blob.Blob) error { return err } - blobTx, err := blob.MarshalBlobTx(txBytes, b) + blobTx, err := blob.MarshalBlobTx(txBytes, b...) if err != nil { return err } diff --git a/x/blob/client/cli/util.go b/x/blob/client/cli/util.go new file mode 100644 index 0000000000..6c53d0eab7 --- /dev/null +++ b/x/blob/client/cli/util.go @@ -0,0 +1,32 @@ +package cli + +import ( + "encoding/json" + "os" +) + +// Define the raw content from the file input. +type blobs struct { + Blobs []blobJSON +} + +type blobJSON struct { + NamespaceID string + Blob string +} + +func parseSubmitBlobs(path string) ([]blobJSON, error) { + var rawBlobs blobs + + content, err := os.ReadFile(path) + if err != nil { + return []blobJSON{}, err + } + + err = json.Unmarshal(content, &rawBlobs) + if err != nil { + return []blobJSON{}, err + } + + return rawBlobs.Blobs, err +} diff --git a/x/blob/client/testutil/integration_test.go b/x/blob/client/testutil/integration_test.go index 886777c4cf..e22865907d 100644 --- a/x/blob/client/testutil/integration_test.go +++ b/x/blob/client/testutil/integration_test.go @@ -3,12 +3,14 @@ package testutil import ( "encoding/hex" "fmt" + "os" "strconv" "testing" "github.com/cosmos/cosmos-sdk/client/flags" "github.com/cosmos/cosmos-sdk/crypto/keyring" "github.com/gogo/protobuf/proto" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" clitestutil "github.com/cosmos/cosmos-sdk/testutil/cli" @@ -35,6 +37,29 @@ type IntegrationTestSuite struct { kr keyring.Keyring } +// Create a .json file for testing +func createTestFile(t testing.TB, s string, isValid bool) *os.File { + t.Helper() + + tempdir, err := os.MkdirTemp("", "") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(tempdir) }) + + var fp *os.File + + if isValid { + fp, err = os.CreateTemp(tempdir, "*.json") + } else { + fp, err = os.CreateTemp(tempdir, "") + } + require.NoError(t, err) + _, err = fp.WriteString(s) + + require.Nil(t, err) + + return fp +} + func NewIntegrationTestSuite(cfg cosmosnet.Config) *IntegrationTestSuite { return &IntegrationTestSuite{cfg: cfg} } @@ -57,9 +82,26 @@ func (s *IntegrationTestSuite) TearDownSuite() { func (s *IntegrationTestSuite) TestSubmitPayForBlob() { require := s.Require() validator := s.network.Validators[0] - hexNamespace := hex.EncodeToString(appns.RandomBlobNamespaceID()) + hexBlob := "0204033704032c0b162109000908094d425837422c2116" + validBlob := fmt.Sprintf(` + { + "Blobs": [ + { + "namespaceID": "%s", + "blob": "%s" + }, + { + "namespaceID": "%s", + "blob": "%s" + } + ] + } + `, hex.EncodeToString(appns.RandomBlobNamespaceID()), hexBlob, hex.EncodeToString(appns.RandomBlobNamespaceID()), hexBlob) + validPropFile := createTestFile(s.T(), validBlob, true) + invalidPropFile := createTestFile(s.T(), validBlob, false) + testCases := []struct { name string args []string @@ -68,9 +110,9 @@ func (s *IntegrationTestSuite) TestSubmitPayForBlob() { respType proto.Message }{ { - name: "valid transaction", + name: "single blob valid transaction", args: []string{ - hexNamespace, + hex.EncodeToString(appns.RandomBlobNamespaceID()), hexBlob, fmt.Sprintf("--from=%s", username), fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastBlock), @@ -81,6 +123,32 @@ func (s *IntegrationTestSuite) TestSubmitPayForBlob() { expectedCode: 0, respType: &sdk.TxResponse{}, }, + { + name: "multiple blobs valid transaction", + args: []string{ + fmt.Sprintf("--from=%s", username), + fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastBlock), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(2))).String()), + fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), + fmt.Sprintf("--%s=%s", paycli.FlagFileInput, validPropFile.Name()), + }, + expectErr: false, + expectedCode: 0, + respType: &sdk.TxResponse{}, + }, + { + name: "multiple blobs with invalid file path extension", + args: []string{ + fmt.Sprintf("--from=%s", username), + fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastBlock), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(2))).String()), + fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), + fmt.Sprintf("--%s=%s", paycli.FlagFileInput, invalidPropFile.Name()), + }, + expectErr: true, + expectedCode: 0, + respType: &sdk.TxResponse{}, + }, } for _, tc := range testCases {