From 960b7901ce8011067b6aa216f80ad67a2016be24 Mon Sep 17 00:00:00 2001 From: containerman17 <8990432+containerman17@users.noreply.github.com> Date: Sat, 26 Oct 2024 00:14:29 +0900 Subject: [PATCH] Universal CLI (#1662) Creates a single binary that can be used with any HyperSDK VM, as long as the VM uses standard functionality. Signed-off-by: containerman17 <8990432+containerman17@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Aaron Buchwald --- abi/abi.go | 45 ++++ abi/dynamic/reflect_marshal.go | 60 +++-- abi/dynamic/reflect_marshal_test.go | 8 +- api/indexer/indexer.go | 16 +- api/indexer/server.go | 4 +- api/jsonrpc/client.go | 13 +- api/ws/client.go | 6 +- chain/transaction.go | 22 +- chain/transaction_test.go | 61 +++-- cli/prompt/prompt.go | 90 +++++-- cmd/hypersdk-cli/README.md | 140 ++++++++++ cmd/hypersdk-cli/actions.go | 75 ++++++ cmd/hypersdk-cli/address.go | 50 ++++ cmd/hypersdk-cli/config.go | 116 ++++++++ cmd/hypersdk-cli/endpoint.go | 36 +++ cmd/hypersdk-cli/endpoint_set.go | 53 ++++ cmd/hypersdk-cli/key.go | 17 ++ cmd/hypersdk-cli/key_set.go | 97 +++++++ cmd/hypersdk-cli/main.go | 35 +++ cmd/hypersdk-cli/ping.go | 51 ++++ cmd/hypersdk-cli/read.go | 270 +++++++++++++++++++ cmd/hypersdk-cli/transaction.go | 178 ++++++++++++ codec/hex.go | 4 + codec/hex_test.go | 15 ++ consts/consts.go | 1 + examples/morpheusvm/README.md | 12 +- examples/morpheusvm/actions/transfer_test.go | 4 +- examples/morpheusvm/storage/storage.go | 2 +- go.mod | 2 +- 29 files changed, 1393 insertions(+), 90 deletions(-) create mode 100644 cmd/hypersdk-cli/README.md create mode 100644 cmd/hypersdk-cli/actions.go create mode 100644 cmd/hypersdk-cli/address.go create mode 100644 cmd/hypersdk-cli/config.go create mode 100644 cmd/hypersdk-cli/endpoint.go create mode 100644 cmd/hypersdk-cli/endpoint_set.go create mode 100644 cmd/hypersdk-cli/key.go create mode 100644 cmd/hypersdk-cli/key_set.go create mode 100644 cmd/hypersdk-cli/main.go create mode 100644 cmd/hypersdk-cli/ping.go create mode 100644 cmd/hypersdk-cli/read.go create mode 100644 cmd/hypersdk-cli/transaction.go diff --git a/abi/abi.go b/abi/abi.go index d605aebacb..1004b9c812 100644 --- a/abi/abi.go +++ b/abi/abi.go @@ -200,3 +200,48 @@ func describeStruct(t reflect.Type) ([]Field, []reflect.Type, error) { return fields, otherStructsSeen, nil } + +func (a *ABI) FindOutputByID(id uint8) (TypedStruct, bool) { + for _, output := range a.Outputs { + if output.ID == id { + return output, true + } + } + return TypedStruct{}, false +} + +func (a *ABI) FindActionByID(id uint8) (TypedStruct, bool) { + for _, action := range a.Actions { + if action.ID == id { + return action, true + } + } + return TypedStruct{}, false +} + +func (a *ABI) FindOutputByName(name string) (TypedStruct, bool) { + for _, output := range a.Outputs { + if output.Name == name { + return output, true + } + } + return TypedStruct{}, false +} + +func (a *ABI) FindActionByName(name string) (TypedStruct, bool) { + for _, action := range a.Actions { + if action.Name == name { + return action, true + } + } + return TypedStruct{}, false +} + +func (a *ABI) FindTypeByName(name string) (Type, bool) { + for _, typ := range a.Types { + if typ.Name == name { + return typ, true + } + } + return Type{}, false +} diff --git a/abi/dynamic/reflect_marshal.go b/abi/dynamic/reflect_marshal.go index 90c07a1b3f..c0ecb2ef6a 100644 --- a/abi/dynamic/reflect_marshal.go +++ b/abi/dynamic/reflect_marshal.go @@ -24,7 +24,7 @@ import ( var ErrTypeNotFound = errors.New("type not found in ABI") func Marshal(inputABI abi.ABI, typeName string, jsonData string) ([]byte, error) { - if _, ok := findABIType(inputABI, typeName); !ok { + if _, ok := inputABI.FindTypeByName(typeName); !ok { return nil, fmt.Errorf("marshalling %s: %w", typeName, ErrTypeNotFound) } @@ -41,7 +41,21 @@ func Marshal(inputABI abi.ABI, typeName string, jsonData string) ([]byte, error) return nil, fmt.Errorf("failed to unmarshal JSON data: %w", err) } - writer := codec.NewWriter(0, consts.NetworkSizeLimit) + var typeID byte + found := false + for _, action := range inputABI.Actions { + if action.Name == typeName { + typeID = action.ID + found = true + break + } + } + if !found { + return nil, fmt.Errorf("action %s not found in ABI", typeName) + } + + writer := codec.NewWriter(1, consts.NetworkSizeLimit) + writer.PackByte(typeID) if err := codec.LinearCodec.MarshalInto(value, writer.Packer); err != nil { return nil, fmt.Errorf("failed to marshal struct: %w", err) } @@ -49,11 +63,35 @@ func Marshal(inputABI abi.ABI, typeName string, jsonData string) ([]byte, error) return writer.Bytes(), nil } -func Unmarshal(inputABI abi.ABI, typeName string, data []byte) (string, error) { - if _, ok := findABIType(inputABI, typeName); !ok { - return "", fmt.Errorf("unmarshalling %s: %w", typeName, ErrTypeNotFound) +func UnmarshalOutput(inputABI abi.ABI, data []byte) (string, error) { + if len(data) == 0 { + return "", nil + } + + typeID := data[0] + outputType, ok := inputABI.FindOutputByID(typeID) + if !ok { + return "", fmt.Errorf("output with id %d not found in ABI", typeID) + } + + return Unmarshal(inputABI, data[1:], outputType.Name) +} + +func UnmarshalAction(inputABI abi.ABI, data []byte) (string, error) { + if len(data) == 0 { + return "", nil } + typeID := data[0] + actionType, ok := inputABI.FindActionByID(typeID) + if !ok { + return "", fmt.Errorf("action with id %d not found in ABI", typeID) + } + + return Unmarshal(inputABI, data[1:], actionType.Name) +} + +func Unmarshal(inputABI abi.ABI, data []byte, typeName string) (string, error) { typeCache := make(map[string]reflect.Type) typ, err := getReflectType(typeName, inputABI, typeCache) @@ -134,13 +172,14 @@ func getReflectType(abiTypeName string, inputABI abi.ABI, typeCache map[string]r return cachedType, nil } - abiType, ok := findABIType(inputABI, abiTypeName) + abiType, ok := inputABI.FindTypeByName(abiTypeName) if !ok { return nil, fmt.Errorf("type %s not found in ABI", abiTypeName) } // It is a struct, as we don't support anything else as custom types fields := make([]reflect.StructField, len(abiType.Fields)) + for i, field := range abiType.Fields { fieldType, err := getReflectType(field.Type, inputABI, typeCache) if err != nil { @@ -159,12 +198,3 @@ func getReflectType(abiTypeName string, inputABI abi.ABI, typeCache map[string]r return structType, nil } } - -func findABIType(inputABI abi.ABI, typeName string) (abi.Type, bool) { - for _, typ := range inputABI.Types { - if typ.Name == typeName { - return typ, true - } - } - return abi.Type{}, false -} diff --git a/abi/dynamic/reflect_marshal_test.go b/abi/dynamic/reflect_marshal_test.go index 23bc296b9a..1b0c41a65e 100644 --- a/abi/dynamic/reflect_marshal_test.go +++ b/abi/dynamic/reflect_marshal_test.go @@ -52,11 +52,13 @@ func TestDynamicMarshal(t *testing.T) { require.NoError(err) // Compare with expected hex - expectedHex := string(mustReadFile(t, "../testdata/"+tc.name+".hex")) - expectedHex = strings.TrimSpace(expectedHex) + expectedTypeID, found := abi.FindActionByName(tc.typeName) + require.True(found, "action %s not found in ABI", tc.typeName) + + expectedHex := hex.EncodeToString([]byte{expectedTypeID.ID}) + strings.TrimSpace(string(mustReadFile(t, "../testdata/"+tc.name+".hex"))) require.Equal(expectedHex, hex.EncodeToString(objectBytes)) - unmarshaledJSON, err := Unmarshal(abi, tc.typeName, objectBytes) + unmarshaledJSON, err := UnmarshalAction(abi, objectBytes) require.NoError(err) // Compare with expected JSON diff --git a/api/indexer/indexer.go b/api/indexer/indexer.go index 6f70b8928e..809960115a 100644 --- a/api/indexer/indexer.go +++ b/api/indexer/indexer.go @@ -174,6 +174,7 @@ func (i *Indexer) storeTransactions(blk *chain.ExecutedBlock) error { result.Units, result.Fee, result.Outputs, + string(result.Error), ); err != nil { return err } @@ -190,6 +191,7 @@ func (*Indexer) storeTransaction( units fees.Dimensions, fee uint64, outputs [][]byte, + errorStr string, ) error { outputLength := consts.ByteLen // Single byte containing number of outputs for _, output := range outputs { @@ -206,19 +208,20 @@ func (*Indexer) storeTransaction( for _, output := range outputs { writer.PackBytes(output) } + writer.PackString(errorStr) if err := writer.Err(); err != nil { return err } return batch.Put(txID[:], writer.Bytes()) } -func (i *Indexer) GetTransaction(txID ids.ID) (bool, int64, bool, fees.Dimensions, uint64, [][]byte, error) { +func (i *Indexer) GetTransaction(txID ids.ID) (bool, int64, bool, fees.Dimensions, uint64, [][]byte, string, error) { v, err := i.txDB.Get(txID[:]) if errors.Is(err, database.ErrNotFound) { - return false, 0, false, fees.Dimensions{}, 0, nil, nil + return false, 0, false, fees.Dimensions{}, 0, nil, "", nil } if err != nil { - return false, 0, false, fees.Dimensions{}, 0, nil, err + return false, 0, false, fees.Dimensions{}, 0, nil, "", err } reader := codec.NewReader(v, consts.NetworkSizeLimit) timestamp := reader.UnpackUint64(true) @@ -231,14 +234,15 @@ func (i *Indexer) GetTransaction(txID ids.ID) (bool, int64, bool, fees.Dimension for i := range outputs { outputs[i] = reader.UnpackLimitedBytes(consts.NetworkSizeLimit) } + errorStr := reader.UnpackString(false) if err := reader.Err(); err != nil { - return false, 0, false, fees.Dimensions{}, 0, nil, err + return false, 0, false, fees.Dimensions{}, 0, nil, "", err } dimensions, err := fees.UnpackDimensions(dimensionsBytes) if err != nil { - return false, 0, false, fees.Dimensions{}, 0, nil, err + return false, 0, false, fees.Dimensions{}, 0, nil, "", err } - return true, int64(timestamp), success, dimensions, fee, outputs, nil + return true, int64(timestamp), success, dimensions, fee, outputs, errorStr, nil } func (i *Indexer) Close() error { diff --git a/api/indexer/server.go b/api/indexer/server.go index f555bdc6c9..e8cec42f03 100644 --- a/api/indexer/server.go +++ b/api/indexer/server.go @@ -111,6 +111,7 @@ type GetTxResponse struct { Units fees.Dimensions `json:"units"` Fee uint64 `json:"fee"` Outputs []codec.Bytes `json:"result"` + ErrorStr string `json:"errorStr"` } type Server struct { @@ -122,7 +123,7 @@ func (s *Server) GetTx(req *http.Request, args *GetTxRequest, reply *GetTxRespon _, span := s.tracer.Start(req.Context(), "Indexer.GetTx") defer span.End() - found, t, success, units, fee, outputs, err := s.indexer.GetTransaction(args.TxID) + found, t, success, units, fee, outputs, errorStr, err := s.indexer.GetTransaction(args.TxID) if err != nil { return err } @@ -139,5 +140,6 @@ func (s *Server) GetTx(req *http.Request, args *GetTxRequest, reply *GetTxRespon wrappedOutputs[i] = codec.Bytes(output) } reply.Outputs = wrappedOutputs + reply.ErrorStr = errorStr return nil } diff --git a/api/jsonrpc/client.go b/api/jsonrpc/client.go index ea3b29acaa..f851570704 100644 --- a/api/jsonrpc/client.go +++ b/api/jsonrpc/client.go @@ -194,19 +194,10 @@ func (cli *JSONRPCClient) GetABI(ctx context.Context) (abi.ABI, error) { return resp.ABI, err } -func (cli *JSONRPCClient) ExecuteActions(ctx context.Context, actor codec.Address, actions []chain.Action) ([][]byte, error) { - actionsMarshaled := make([][]byte, 0, len(actions)) - for _, action := range actions { - actionBytes, err := chain.MarshalTyped(action) - if err != nil { - return nil, fmt.Errorf("failed to marshal action: %w", err) - } - actionsMarshaled = append(actionsMarshaled, actionBytes) - } - +func (cli *JSONRPCClient) ExecuteActions(ctx context.Context, actor codec.Address, actionsBytes [][]byte) ([][]byte, error) { args := &ExecuteActionArgs{ Actor: actor, - Actions: actionsMarshaled, + Actions: actionsBytes, } resp := new(ExecuteActionReply) diff --git a/api/ws/client.go b/api/ws/client.go index ed97aee342..86ae111720 100644 --- a/api/ws/client.go +++ b/api/ws/client.go @@ -165,10 +165,14 @@ func (c *WebSocketClient) ListenBlock( // IssueTx sends [tx] to the streaming rpc server. func (c *WebSocketClient) RegisterTx(tx *chain.Transaction) error { + return c.RegisterRawTx(tx.Bytes()) +} + +func (c *WebSocketClient) RegisterRawTx(txBytes []byte) error { if c.closed { return ErrClosed } - return c.mb.Send(append([]byte{TxMode}, tx.Bytes()...)) + return c.mb.Send(append([]byte{TxMode}, txBytes...)) } // ListenTx listens for responses from the streamingServer. diff --git a/chain/transaction.go b/chain/transaction.go index a0908d4bd6..4903b7b9b4 100644 --- a/chain/transaction.go +++ b/chain/transaction.go @@ -102,6 +102,24 @@ func (t *TransactionData) Sign( return UnmarshalTx(p, actionCodec, authCodec) } +func SignRawActionBytesTx( + base *Base, + rawActionsBytes []byte, + authFactory AuthFactory, +) ([]byte, error) { + p := codec.NewWriter(base.Size(), consts.NetworkSizeLimit) + base.Marshal(p) + p.PackFixedBytes(rawActionsBytes) + + auth, err := authFactory.Sign(p.Bytes()) + if err != nil { + return nil, err + } + p.PackByte(auth.GetTypeID()) + auth.Marshal(p) + return p.Bytes(), p.Err() +} + func (t *TransactionData) Expiry() int64 { return t.Base.Timestamp } func (t *TransactionData) MaxFee() uint64 { return t.Base.MaxFee } @@ -116,7 +134,7 @@ func (t *TransactionData) Marshal(p *codec.Packer) error { func (t *TransactionData) marshal(p *codec.Packer) error { t.Base.Marshal(p) - return t.Actions.marshalInto(p) + return t.Actions.MarshalInto(p) } type Actions []Action @@ -133,7 +151,7 @@ func (a Actions) Size() (int, error) { return size, nil } -func (a Actions) marshalInto(p *codec.Packer) error { +func (a Actions) MarshalInto(p *codec.Packer) error { p.PackByte(uint8(len(a))) for _, action := range a { p.PackByte(action.GetTypeID()) diff --git a/chain/transaction_test.go b/chain/transaction_test.go index d00ed512c4..999c906110 100644 --- a/chain/transaction_test.go +++ b/chain/transaction_test.go @@ -13,6 +13,7 @@ import ( "github.com/ava-labs/hypersdk/auth" "github.com/ava-labs/hypersdk/chain" "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/consts" "github.com/ava-labs/hypersdk/crypto/ed25519" "github.com/ava-labs/hypersdk/state" ) @@ -115,7 +116,33 @@ func TestMarshalUnmarshal(t *testing.T) { err = actionCodec.Register(&action2{}, unmarshalAction2) require.NoError(err) - txBeforeSign := chain.TransactionData{ + // call UnsignedBytes so that the "unsignedBytes" field would get populated. + txBeforeSignBytes, err := tx.UnsignedBytes() + require.NoError(err) + + signedTx, err := tx.Sign(factory, actionCodec, authCodec) + require.NoError(err) + unsignedTxAfterSignBytes, err := signedTx.TransactionData.UnsignedBytes() + require.NoError(err) + require.Equal(txBeforeSignBytes, unsignedTxAfterSignBytes) + require.NotNil(signedTx.Auth) + require.Equal(len(signedTx.Actions), len(tx.Actions)) + for i, action := range signedTx.Actions { + require.Equal(tx.Actions[i], action) + } + + unsignedTxBytes, err := signedTx.UnsignedBytes() + require.NoError(err) + originalUnsignedTxBytes, err := tx.UnsignedBytes() + require.NoError(err) + + require.Equal(unsignedTxBytes, originalUnsignedTxBytes) + require.Len(unsignedTxBytes, 168) +} + +func TestSignRawActionBytesTx(t *testing.T) { + require := require.New(t) + tx := chain.TransactionData{ Base: &chain.Base{ Timestamp: 1724315246000, ChainID: [32]byte{1, 2, 3, 4, 5, 6, 7}, @@ -138,24 +165,28 @@ func TestMarshalUnmarshal(t *testing.T) { }, }, } - // call UnsignedBytes so that the "unsignedBytes" field would get populated. - _, err = txBeforeSign.UnsignedBytes() - require.NoError(err) - signedTx, err := tx.Sign(factory, actionCodec, authCodec) + priv, err := ed25519.GeneratePrivateKey() require.NoError(err) - require.Equal(txBeforeSign, tx) - require.NotNil(signedTx.Auth) - require.Equal(len(signedTx.Actions), len(tx.Actions)) - for i, action := range signedTx.Actions { - require.Equal(tx.Actions[i], action) - } + factory := auth.NewED25519Factory(priv) - unsignedTxBytes, err := signedTx.UnsignedBytes() + actionCodec := codec.NewTypeParser[chain.Action]() + authCodec := codec.NewTypeParser[chain.Auth]() + + err = authCodec.Register(&auth.ED25519{}, auth.UnmarshalED25519) require.NoError(err) - originalUnsignedTxBytes, err := tx.UnsignedBytes() + err = actionCodec.Register(&mockTransferAction{}, unmarshalTransfer) + require.NoError(err) + err = actionCodec.Register(&action2{}, unmarshalAction2) require.NoError(err) - require.Equal(unsignedTxBytes, originalUnsignedTxBytes) - require.Len(unsignedTxBytes, 168) + signedTx, err := tx.Sign(factory, actionCodec, authCodec) + require.NoError(err) + + p := codec.NewWriter(0, consts.NetworkSizeLimit) + require.NoError(signedTx.Actions.MarshalInto(p)) + actionsBytes := p.Bytes() + rawSignedTxBytes, err := chain.SignRawActionBytesTx(tx.Base, actionsBytes, factory) + require.NoError(err) + require.Equal(signedTx.Bytes(), rawSignedTxBytes) } diff --git a/cli/prompt/prompt.go b/cli/prompt/prompt.go index a5febf57b5..3b3f556e41 100644 --- a/cli/prompt/prompt.go +++ b/cli/prompt/prompt.go @@ -4,9 +4,9 @@ package prompt import ( - "encoding/hex" "errors" "fmt" + "math" "strconv" "strings" @@ -34,10 +34,7 @@ func Bytes(label string) ([]byte, error) { promptText := promptui.Prompt{ Label: label, Validate: func(input string) error { - if len(input) == 0 { - return ErrInputEmpty - } - _, err := hex.DecodeString(input) + _, err := codec.LoadHex(input, -1) return err }, } @@ -45,17 +42,15 @@ func Bytes(label string) ([]byte, error) { if err != nil { return nil, err } - return hex.DecodeString(hexString) + return codec.LoadHex(hexString, -1) } func Address(label string) (codec.Address, error) { promptText := promptui.Prompt{ Label: label, Validate: func(input string) error { - if len(input) == 0 { - return ErrInputEmpty - } - return nil + _, err := codec.StringToAddress(strings.TrimSpace(input)) + return err }, } recipient, err := promptText.Run() @@ -158,31 +153,74 @@ func Int( label string, max int, ) (int, error) { + stringToInt := func(input string, max int) (int, error) { + input = strings.TrimSpace(input) + + if len(input) == 0 { + return 0, ErrInputEmpty + } + amount, err := strconv.Atoi(input) + if err != nil { + return 0, err + } + if amount <= 0 { + return 0, fmt.Errorf("%d must be > 0", amount) + } + if amount > max { + return 0, fmt.Errorf("%d must be <= %d", amount, max) + } + return amount, nil + } + promptText := promptui.Prompt{ Label: label, Validate: func(input string) error { - if len(input) == 0 { - return ErrInputEmpty - } - amount, err := strconv.Atoi(input) - if err != nil { - return err - } - if amount <= 0 { - return fmt.Errorf("%d must be > 0", amount) - } - if amount > max { - return fmt.Errorf("%d must be <= %d", amount, max) - } - return nil + _, err := stringToInt(input, max) + return err }, } rawAmount, err := promptText.Run() if err != nil { return 0, err } - rawAmount = strings.TrimSpace(rawAmount) - return strconv.Atoi(rawAmount) + return stringToInt(rawAmount, max) +} + +func Uint( + label string, + max uint, +) (uint, error) { + stringToUint := func(input string, max uint) (uint, error) { + input = strings.TrimSpace(input) + + if len(input) == 0 { + return 0, ErrInputEmpty + } + amount, err := strconv.ParseUint(input, 10, 0) + if err != nil { + return 0, err + } + if amount > math.MaxUint { + return 0, fmt.Errorf("%d exceeds the maximum value for uint", amount) + } + if uint(amount) > max { + return 0, fmt.Errorf("%d must be <= %d", amount, max) + } + return uint(amount), nil + } + + promptText := promptui.Prompt{ + Label: label, + Validate: func(input string) error { + _, err := stringToUint(input, max) + return err + }, + } + rawAmount, err := promptText.Run() + if err != nil { + return 0, err + } + return stringToUint(rawAmount, max) } func Float( diff --git a/cmd/hypersdk-cli/README.md b/cmd/hypersdk-cli/README.md new file mode 100644 index 0000000000..93d790a951 --- /dev/null +++ b/cmd/hypersdk-cli/README.md @@ -0,0 +1,140 @@ +# HyperSDK CLI + +A command-line interface for interacting with HyperSDK-based chains. + +## Installation + +```bash +go install github.com/ava-labs/hypersdk/cmd/hypersdk-cli@4510f51720d2e0fdecfd7fa08350e7c3eab3cf53 +``` + +FIXME: Has to point to the commit with the latest update from main, or just `@main` later on. + +## Configuration + +The CLI stores configuration in `~/.hypersdk-cli/config.yaml`. This includes: +- Private key +- Endpoint URL + +Example setup for a local HyperSDK VM: +```bash +hypersdk-cli endpoint set --endpoint=http://localhost:9650/ext/bc/morpheusvm/ +hypersdk-cli key set --key=0x323b1d8f4eed5f0da9da93071b034f2dce9d2d22692c172f3cb252a64ddfafd01b057de320297c29ad0c1f589ea216869cf1938d88c9fbd70d6748323dbf2fa7 +``` + +## Global Flags + +- `--endpoint`: Override the default endpoint for a single command +- `-o, --output`: Set output format (`text` or `json`) + +## Commands + + +### key + +Manage keys. + +#### generate + +Generate a new ED25519 key pair. + +```bash +hypersdk-cli key generate +``` + +#### set + +Set the private ED25519 key. + +```bash +hypersdk-cli key set --key=0x323b1d8f4eed5f0da9da93071b034f2dce9d2d22692c172f3cb252a64ddfafd01b057de320297c29ad0c1f589ea216869cf1938d88c9fbd70d6748323dbf2fa7 +``` + +`--key` could also be file path like `examples/morpheusvm/demo.pk` + +### endpoint + +Print the current endpoint URL. + +```bash +hypersdk-cli endpoint +``` + +#### set + +Set the endpoint URL. + +```bash +hypersdk-cli endpoint set --endpoint=http://localhost:9650/ext/bc/morpheusvm/ +``` + +### ping + +Check connectivity with the current endpoint. + +```bash +hypersdk-cli ping +``` + +### address + +Print the current key address. + +```bash +hypersdk-cli address +``` + +### actions + +Print the list of actions available in the ABI. + +```bash +hypersdk-cli actions +``` + +For JSON output: + +```bash +hypersdk-cli actions -o json +``` + +### read + +Simulate a single action transaction. + +```bash +hypersdk-cli read Transfer --data to=0x000000000000000000000000000000000000000000000000000000000000000000,value=12 +``` + +For interactive input remove --data from the comand line: + +```bash +hypersdk-cli read Transfer +``` + +### tx + +Send a transaction with a single action. + +```bash +hypersdk-cli tx Transfer --data to=0x000000000000000000000000000000000000000000000000000000000000000000,value=12,memo=0x001234 +``` + +For interactive input: + +```bash +hypersdk-cli tx Transfer +``` + +## Notes + +- Only flat actions are supported. Arrays, slices, embedded structs, maps, and struct fields are not supported. +- The CLI supports ED25519 keys only. +- If `--data` is supplied or JSON output is selected, the CLI will not ask for action arguments interactively. + +## Known Issues + +- The `balance` command is not currently implemented due to the lack of a standardized balance RPC method at the HyperSDK level. +- The `maxFee` for transactions is currently hardcoded to 1,000,000. +- The `key set` and `endpoint set` commands use a nested command structure which adds unnecessary complexity for a small CLI tool. A flatter command structure would be more appropriate. +- Currency values are represented as uint64 without decimal point support in the ABI. The CLI cannot automatically parse decimal inputs (e.g. "12.0") since there is no currency type annotation. Users must enter the raw uint64 value including all decimal places (e.g. "12000000000" for 12 coins with 9 decimal places). diff --git a/cmd/hypersdk-cli/actions.go b/cmd/hypersdk-cli/actions.go new file mode 100644 index 0000000000..d82d08fbae --- /dev/null +++ b/cmd/hypersdk-cli/actions.go @@ -0,0 +1,75 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/abi" + "github.com/ava-labs/hypersdk/api/jsonrpc" +) + +var actionsCmd = &cobra.Command{ + Use: "actions", + Short: "Print the list of actions available in the ABI", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + client := jsonrpc.NewJSONRPCClient(endpoint) + + abi, err := client.GetABI(context.Background()) + if err != nil { + return fmt.Errorf("failed to get ABI: %w", err) + } + + return printValue(cmd, abiWrapper{ABI: abi}) + }, +} + +type abiWrapper struct { + ABI abi.ABI +} + +func (a abiWrapper) String() string { + result := "" + for _, action := range a.ABI.Actions { + result += fmt.Sprintf("---\n%s\n\n", action.Name) + typ, found := a.ABI.FindTypeByName(action.Name) + if !found { + result += fmt.Sprintf(" Error: Type not found for action %s\n", action.Name) + continue + } else { + result += "Inputs:\n" + for _, field := range typ.Fields { + result += fmt.Sprintf(" %s: %s\n", field.Name, field.Type) + } + } + + output, found := a.ABI.FindOutputByID(action.ID) + if !found { + result += fmt.Sprintf("No outputs for %s with id %d\n", action.Name, action.ID) + continue + } + + typ, found = a.ABI.FindTypeByName(output.Name) + if !found { + result += fmt.Sprintf(" Error: Type not found for output %s\n", output.Name) + continue + } + result += "\nOutputs:\n" + for _, field := range typ.Fields { + result += fmt.Sprintf(" %s: %s\n", field.Name, field.Type) + } + } + return result +} + +func init() { + rootCmd.AddCommand(actionsCmd) +} diff --git a/cmd/hypersdk-cli/address.go b/cmd/hypersdk-cli/address.go new file mode 100644 index 0000000000..e3651186a8 --- /dev/null +++ b/cmd/hypersdk-cli/address.go @@ -0,0 +1,50 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/auth" +) + +var addressCmd = &cobra.Command{ + Use: "address", + Short: "Print current key address", + RunE: func(cmd *cobra.Command, _ []string) error { + keyString, err := getConfigValue(cmd, "key", true) + if err != nil { + return fmt.Errorf("failed to get key: %w", err) + } + + key, err := privateKeyFromString(keyString) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + + addr := auth.NewED25519Address(key.PublicKey()) + addrString, err := addr.MarshalText() + if err != nil { + return fmt.Errorf("failed to marshal address: %w", err) + } + + return printValue(cmd, keyAddressCmdResponse{ + Address: string(addrString), + }) + }, +} + +type keyAddressCmdResponse struct { + Address string `json:"address"` +} + +func (r keyAddressCmdResponse) String() string { + return r.Address +} + +func init() { + rootCmd.AddCommand(addressCmd) +} diff --git a/cmd/hypersdk-cli/config.go b/cmd/hypersdk-cli/config.go new file mode 100644 index 0000000000..cfb51bb61c --- /dev/null +++ b/cmd/hypersdk-cli/config.go @@ -0,0 +1,116 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/ava-labs/hypersdk/codec" +) + +func init() { + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Fprintln(os.Stderr, "Error getting home directory:", err) + os.Exit(1) + } + + configDir := filepath.Join(homeDir, ".hypersdk-cli") + if err := os.MkdirAll(configDir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, "Error creating config directory:", err) + os.Exit(1) + } + + configFile := filepath.Join(configDir, "config.yaml") + if _, err := os.Stat(configFile); os.IsNotExist(err) { + if _, err := os.Create(configFile); err != nil { + fmt.Fprintln(os.Stderr, "Error creating config file:", err) + os.Exit(1) + } + } + + // Set config name and paths + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(configDir) + + // Read config + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + fmt.Fprintln(os.Stderr, "Error reading config:", err) + os.Exit(1) + } + // Config file not found; will be created when needed + } +} + +func isJSONOutputRequested(cmd *cobra.Command) (bool, error) { + output, err := getConfigValue(cmd, "output", false) + if err != nil { + return false, fmt.Errorf("failed to get output format: %w", err) + } + return strings.ToLower(output) == "json", nil +} + +func printValue(cmd *cobra.Command, v fmt.Stringer) error { + isJSON, err := isJSONOutputRequested(cmd) + if err != nil { + return err + } + + if isJSON { + jsonBytes, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } else { + fmt.Println(v.String()) + return nil + } +} + +func getConfigValue(cmd *cobra.Command, key string, required bool) (string, error) { + // Check flags first + if value, err := cmd.Flags().GetString(key); err == nil && value != "" { + return value, nil + } + + // Then check viper + if value := viper.GetString(key); value != "" { + return value, nil + } + + if required { + return "", fmt.Errorf("required value for %s not found", key) + } + + return "", nil +} + +func setConfigValue(key, value string) error { + viper.Set(key, value) + return viper.WriteConfig() +} + +func decodeFileOrHex(fileNameOrHex string) ([]byte, error) { + if decoded, err := codec.LoadHex(fileNameOrHex, -1); err == nil { + return decoded, nil + } + + if fileContents, err := os.ReadFile(fileNameOrHex); err == nil { + return fileContents, nil + } + + return nil, errors.New("unable to decode input as hex, or read as file path") +} diff --git a/cmd/hypersdk-cli/endpoint.go b/cmd/hypersdk-cli/endpoint.go new file mode 100644 index 0000000000..745bdf5100 --- /dev/null +++ b/cmd/hypersdk-cli/endpoint.go @@ -0,0 +1,36 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var endpointCmd = &cobra.Command{ + Use: "endpoint", + Short: "Manage endpoint", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + return printValue(cmd, endpointCmdResponse{ + Endpoint: endpoint, + }) + }, +} + +type endpointCmdResponse struct { + Endpoint string `json:"endpoint"` +} + +func (r endpointCmdResponse) String() string { + return r.Endpoint +} + +func init() { + rootCmd.AddCommand(endpointCmd) +} diff --git a/cmd/hypersdk-cli/endpoint_set.go b/cmd/hypersdk-cli/endpoint_set.go new file mode 100644 index 0000000000..e84dffa739 --- /dev/null +++ b/cmd/hypersdk-cli/endpoint_set.go @@ -0,0 +1,53 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "errors" + "fmt" + "log" + + "github.com/spf13/cobra" +) + +var endpointSetCmd = &cobra.Command{ + Use: "set", + Short: "Set the endpoint URL", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, err := cmd.Flags().GetString("endpoint") + if err != nil { + return fmt.Errorf("failed to get endpoint flag: %w", err) + } + + if endpoint == "" { + return errors.New("endpoint is required") + } + + if err := setConfigValue("endpoint", endpoint); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + return printValue(cmd, endpointSetCmdResponse{ + Endpoint: endpoint, + }) + }, +} + +type endpointSetCmdResponse struct { + Endpoint string `json:"endpoint"` +} + +func (r endpointSetCmdResponse) String() string { + return "Endpoint set to: " + r.Endpoint +} + +func init() { + endpointCmd.AddCommand(endpointSetCmd) + endpointSetCmd.Flags().String("endpoint", "", "Endpoint URL to set") + + err := endpointSetCmd.MarkFlagRequired("endpoint") + if err != nil { + log.Fatalf("failed to mark endpoint flag as required: %s", err) + } +} diff --git a/cmd/hypersdk-cli/key.go b/cmd/hypersdk-cli/key.go new file mode 100644 index 0000000000..d2f48c5064 --- /dev/null +++ b/cmd/hypersdk-cli/key.go @@ -0,0 +1,17 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "github.com/spf13/cobra" +) + +var keyCmd = &cobra.Command{ + Use: "key", + Short: "Manage keys", +} + +func init() { + rootCmd.AddCommand(keyCmd) +} diff --git a/cmd/hypersdk-cli/key_set.go b/cmd/hypersdk-cli/key_set.go new file mode 100644 index 0000000000..22eef68779 --- /dev/null +++ b/cmd/hypersdk-cli/key_set.go @@ -0,0 +1,97 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "encoding/hex" + "errors" + "fmt" + "log" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/crypto/ed25519" +) + +var keyGenerateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate a new key", + RunE: func(cmd *cobra.Command, _ []string) error { + newKey, err := ed25519.GeneratePrivateKey() + if err != nil { + return fmt.Errorf("failed to generate key: %w", err) + } + + return checkAndSavePrivateKey(cmd, hex.EncodeToString(newKey[:])) + }, +} + +var keySetCmd = &cobra.Command{ + Use: "set", + Short: "Set the private ED25519 key", + RunE: func(cmd *cobra.Command, _ []string) error { + keyString, err := cmd.Flags().GetString("key") + if err != nil { + return fmt.Errorf("failed to get key flag: %w", err) + } + if keyString == "" { + return errors.New("--key is required") + } + + return checkAndSavePrivateKey(cmd, keyString) + }, +} + +func checkAndSavePrivateKey(cmd *cobra.Command, keyStr string) error { + key, err := privateKeyFromString(keyStr) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + + // Use Viper to save the key + if err := setConfigValue("key", hex.EncodeToString(key[:])); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + addr := auth.NewED25519Address(key.PublicKey()) + + addrString, err := addr.MarshalText() + if err != nil { + return fmt.Errorf("failed to marshal address: %w", err) + } + + return printValue(cmd, keySetCmdResponse{ + Address: string(addrString), + }) +} + +func privateKeyFromString(keyStr string) (ed25519.PrivateKey, error) { + keyBytes, err := decodeFileOrHex(keyStr) + if err != nil { + return ed25519.EmptyPrivateKey, fmt.Errorf("failed to decode key: %w", err) + } + if len(keyBytes) != ed25519.PrivateKeyLen { + return ed25519.EmptyPrivateKey, fmt.Errorf("invalid private key length: %d", len(keyBytes)) + } + return ed25519.PrivateKey(keyBytes), nil +} + +type keySetCmdResponse struct { + Address string `json:"address"` +} + +func (r keySetCmdResponse) String() string { + return "✅ Key added successfully!\nAddress: " + r.Address +} + +func init() { + keyCmd.AddCommand(keySetCmd, keyGenerateCmd) + keySetCmd.Flags().String("key", "", "Private key in hex format or path to file containing the key") + + err := keySetCmd.MarkFlagRequired("key") + if err != nil { + log.Fatalf("failed to mark key flag as required: %s", err) + } +} diff --git a/cmd/hypersdk-cli/main.go b/cmd/hypersdk-cli/main.go new file mode 100644 index 0000000000..185c8c19df --- /dev/null +++ b/cmd/hypersdk-cli/main.go @@ -0,0 +1,35 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "hypersdk-cli", + Short: "HyperSDK CLI for interacting with HyperSDK-based chains", + Long: `A CLI application for performing read and write actions on HyperSDK-based chains.`, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} + +func init() { + rootCmd.PersistentFlags().StringP("output", "o", "text", "Output format (text or json)") + rootCmd.PersistentFlags().String("endpoint", "", "Override the default endpoint") + rootCmd.PersistentFlags().String("key", "", "Private ED25519 key as hex string") +} + +func main() { + Execute() +} diff --git a/cmd/hypersdk-cli/ping.go b/cmd/hypersdk-cli/ping.go new file mode 100644 index 0000000000..a4a66e071e --- /dev/null +++ b/cmd/hypersdk-cli/ping.go @@ -0,0 +1,51 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/api/jsonrpc" +) + +var endpointPingCmd = &cobra.Command{ + Use: "ping", + Short: "Ping the endpoint", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + client := jsonrpc.NewJSONRPCClient(endpoint) + + success, err := client.Ping(context.Background()) + pingErr := "" + if err != nil { + pingErr = err.Error() + } + return printValue(cmd, pingResponse{ + PingSucceed: success, + PingError: pingErr, + }) + }, +} + +type pingResponse struct { + PingSucceed bool `json:"ping_succeed"` + PingError string `json:"ping_error"` +} + +func (r pingResponse) String() string { + if r.PingSucceed { + return "✅ Ping succeeded" + } + return "❌ Ping failed with error: " + r.PingError +} + +func init() { + rootCmd.AddCommand(endpointPingCmd) +} diff --git a/cmd/hypersdk-cli/read.go b/cmd/hypersdk-cli/read.go new file mode 100644 index 0000000000..4ce610dd50 --- /dev/null +++ b/cmd/hypersdk-cli/read.go @@ -0,0 +1,270 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/abi" + "github.com/ava-labs/hypersdk/abi/dynamic" + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/cli/prompt" + "github.com/ava-labs/hypersdk/codec" +) + +var readCmd = &cobra.Command{ + Use: "read [action]", + Short: "Read data from the chain", + RunE: func(cmd *cobra.Command, args []string) error { + // 1. figure out sender address + senderStr, err := cmd.Flags().GetString("sender") + if err != nil { + return fmt.Errorf("failed to get sender: %w", err) + } + + var sender codec.Address + + if senderStr != "" { + sender, err = codec.StringToAddress(senderStr) + if err != nil { + return fmt.Errorf("failed to convert sender to address: %w", err) + } + } else { + // ok, infer user's address from the private key + keyString, err := getConfigValue(cmd, "key", true) + if err != nil { + return fmt.Errorf("failed to get key from config: %w", err) + } + key, err := privateKeyFromString(keyString) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + sender = auth.NewED25519Address(key.PublicKey()) + } + + // 2. create client + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + client := jsonrpc.NewJSONRPCClient(endpoint) + + // 3. get abi + abi, err := client.GetABI(context.Background()) + if err != nil { + return fmt.Errorf("failed to get abi: %w", err) + } + + // 4. get action name from args + if len(args) == 0 { + return errors.New("action name is required") + } + actionName := args[0] + _, found := abi.FindActionByName(actionName) + if !found { + return fmt.Errorf("failed to find action: %s", actionName) + } + + typ, found := abi.FindTypeByName(actionName) + if !found { + return fmt.Errorf("failed to find type: %s", actionName) + } + + // 5. create action using kvPairs + kvPairs, err := fillAction(cmd, typ) + if err != nil { + return err + } + + jsonPayload, err := json.Marshal(kvPairs) + if err != nil { + return fmt.Errorf("failed to marshal kvPairs: %w", err) + } + + actionBytes, err := dynamic.Marshal(abi, actionName, string(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to marshal action: %w", err) + } + + results, executeErr := client.ExecuteActions(context.Background(), sender, [][]byte{actionBytes}) + var resultStruct map[string]interface{} + + if len(results) == 1 { + resultJSON, err := dynamic.UnmarshalOutput(abi, results[0]) + if err != nil { + return fmt.Errorf("failed to unmarshal result: %w", err) + } + + err = json.Unmarshal([]byte(resultJSON), &resultStruct) + if err != nil { + return fmt.Errorf("failed to unmarshal result JSON: %w", err) + } + } + + errorString := "" + if executeErr != nil { + errorString = executeErr.Error() + } + + return printValue(cmd, readResponse{ + Result: resultStruct, + Success: executeErr == nil, + Error: errorString, + }) + }, +} + +func fillAction(cmd *cobra.Command, typ abi.Type) (map[string]interface{}, error) { + // get key-value pairs + inputData, err := cmd.Flags().GetStringToString("data") + if err != nil { + return nil, fmt.Errorf("failed to get data key-value pairs: %w", err) + } + + isJSONOutput, err := isJSONOutputRequested(cmd) + if err != nil { + return nil, fmt.Errorf("failed to get output format: %w", err) + } + + isInteractive := len(inputData) == 0 && !isJSONOutput + + var kvPairs map[string]interface{} + if isInteractive { + kvPairs, err = askForFlags(typ) + if err != nil { + return nil, fmt.Errorf("failed to ask for flags: %w", err) + } + } else { + kvPairs, err = fillFromInputData(typ, inputData) + if err != nil { + return nil, fmt.Errorf("failed to fill from kvData: %w", err) + } + } + + return kvPairs, nil +} + +func fillFromInputData(typ abi.Type, kvData map[string]string) (map[string]interface{}, error) { + // Require exact match in required fields to supplied arguments + if len(kvData) != len(typ.Fields) { + return nil, fmt.Errorf("type has %d fields, got %d arguments", len(typ.Fields), len(kvData)) + } + for _, field := range typ.Fields { + if _, ok := kvData[field.Name]; !ok { + return nil, fmt.Errorf("missing argument: %s", field.Name) + } + } + + kvPairs := make(map[string]interface{}) + for _, field := range typ.Fields { + value := kvData[field.Name] + var parsedValue interface{} + var err error + switch field.Type { + case "Address": + parsedValue = value + case "uint8", "uint16", "uint32", "uint", "uint64": + parsedValue, err = strconv.ParseUint(value, 10, 64) + case "int8", "int16", "int32", "int", "int64": + parsedValue, err = strconv.ParseInt(value, 10, 64) + case "[]uint8": + if value == "" { + parsedValue = []uint8{} + } else { + parsedValue, err = codec.LoadHex(value, -1) + } + case "string": + parsedValue = value + case "bool": + parsedValue, err = strconv.ParseBool(value) + default: + return nil, fmt.Errorf("unsupported field type: %s", field.Type) + } + if err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", field.Name, err) + } + kvPairs[field.Name] = parsedValue + } + return kvPairs, nil +} + +func askForFlags(typ abi.Type) (map[string]interface{}, error) { + kvPairs := make(map[string]interface{}) + + for _, field := range typ.Fields { + var err error + var value interface{} + switch field.Type { + case "Address": + value, err = prompt.Address(field.Name) + case "uint8": + value, err = prompt.Uint(field.Name, math.MaxUint8) + case "uint16": + value, err = prompt.Uint(field.Name, math.MaxUint16) + case "uint32": + value, err = prompt.Uint(field.Name, math.MaxUint32) + case "uint", "uint64": + value, err = prompt.Uint(field.Name, math.MaxUint64) + case "int8": + value, err = prompt.Int(field.Name, math.MaxInt8) + case "int16": + value, err = prompt.Int(field.Name, math.MaxInt16) + case "int32": + value, err = prompt.Int(field.Name, math.MaxInt32) + case "int", "int64": + value, err = prompt.Int(field.Name, math.MaxInt64) + case "[]uint8": + value, err = prompt.Bytes(field.Name) + case "string": + value, err = prompt.String(field.Name, 0, 1024) + case "bool": + value, err = prompt.Bool(field.Name) + default: + return nil, fmt.Errorf("unsupported field type in CLI: %s", field.Type) + } + if err != nil { + return nil, fmt.Errorf("failed to get input for %s field: %w", field.Name, err) + } + kvPairs[field.Name] = value + } + return kvPairs, nil +} + +type readResponse struct { + Result map[string]interface{} `json:"result"` + Success bool `json:"success"` + Error string `json:"error"` +} + +func (r readResponse) String() string { + var result strings.Builder + if r.Success { + result.WriteString("✅ Read-only execution successful:\n") + for key, value := range r.Result { + jsonValue, err := json.Marshal(value) + if err != nil { + jsonValue = []byte(fmt.Sprintf("%v", value)) + } + result.WriteString(fmt.Sprintf("%s: %s\n", key, string(jsonValue))) + } + } else { + result.WriteString(fmt.Sprintf("❌ Read-only execution failed: %s\n", r.Error)) + } + return result.String() +} + +func init() { + readCmd.Flags().String("sender", "", "Address of the sender in hex") + readCmd.Flags().StringToString("data", nil, "Key-value pairs for the action data (e.g., key1=value1,key2=value2)") + rootCmd.AddCommand(readCmd) +} diff --git a/cmd/hypersdk-cli/transaction.go b/cmd/hypersdk-cli/transaction.go new file mode 100644 index 0000000000..d8e6f7d097 --- /dev/null +++ b/cmd/hypersdk-cli/transaction.go @@ -0,0 +1,178 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/ava-labs/avalanchego/ids" + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/abi/dynamic" + "github.com/ava-labs/hypersdk/api/indexer" + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/chain" +) + +var txCmd = &cobra.Command{ + Use: "tx [action]", + Short: "Execute a transaction on the chain", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 1. Decode key + keyString, err := getConfigValue(cmd, "key", true) + if err != nil { + return fmt.Errorf("failed to get key from config: %w", err) + } + key, err := privateKeyFromString(keyString) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + + // 2. create client + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + client := jsonrpc.NewJSONRPCClient(endpoint) + + // 3. get abi + abi, err := client.GetABI(ctx) + if err != nil { + return fmt.Errorf("failed to get abi: %w", err) + } + // 4. get action name from args + if len(args) == 0 { + return errors.New("action name is required") + } + actionName := args[0] + _, found := abi.FindActionByName(actionName) + if !found { + return fmt.Errorf("failed to find action: %s", actionName) + } + + typ, found := abi.FindTypeByName(actionName) + if !found { + return fmt.Errorf("failed to find type: %s", actionName) + } + + // 5. create action using kvPairs + kvPairs, err := fillAction(cmd, typ) + if err != nil { + return err + } + + jsonPayload, err := json.Marshal(kvPairs) + if err != nil { + return fmt.Errorf("failed to marshal kvPairs: %w", err) + } + + actionBytes, err := dynamic.Marshal(abi, actionName, string(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to marshal action: %w", err) + } + + _, _, chainID, err := client.Network(ctx) + if err != nil { + return fmt.Errorf("failed to get network info: %w", err) + } + + base := &chain.Base{ + ChainID: chainID, + Timestamp: time.Now().Unix()*1000 + 60*1000, // TODO: use utils.UnixRMilli(now, rules.GetValidityWindow()) + MaxFee: 1_000_000, // TODO: use chain.EstimateUnits(parser.Rules(time.Now().UnixMilli()), actions, authFactory) + } + + signedBytes, err := chain.SignRawActionBytesTx(base, append([]byte{1}, actionBytes...), auth.NewED25519Factory(key)) + if err != nil { + return fmt.Errorf("failed to sign tx: %w", err) + } + + indexerClient := indexer.NewClient(endpoint) + + expectedTxID, err := client.SubmitTx(ctx, signedBytes) + if err != nil { + return fmt.Errorf("failed to send tx: %w", err) + } + + var getTxResponse indexer.GetTxResponse + for { + if err := ctx.Err(); err != nil { + return fmt.Errorf("context expired while waiting for tx: %w", err) + } + + getTxResponse, found, err = indexerClient.GetTx(ctx, expectedTxID) + if err != nil { + return fmt.Errorf("failed to get tx: %w", err) + } + if found { + break + } + time.Sleep(500 * time.Millisecond) + } + + var resultStruct map[string]interface{} + if getTxResponse.Success { + if len(getTxResponse.Outputs) == 1 { + resultJSON, err := dynamic.UnmarshalOutput(abi, getTxResponse.Outputs[0]) + if err != nil { + return fmt.Errorf("failed to unmarshal result: %w", err) + } + + err = json.Unmarshal([]byte(resultJSON), &resultStruct) + if err != nil { + return fmt.Errorf("failed to unmarshal result JSON: %w", err) + } + } else if len(getTxResponse.Outputs) > 1 { + return fmt.Errorf("expected 1 output, got %d", len(getTxResponse.Outputs)) + } + } + + return printValue(cmd, txResponse{ + Result: resultStruct, + Success: getTxResponse.Success, + TxID: expectedTxID, + Error: getTxResponse.ErrorStr, + }) + }, +} + +type txResponse struct { + Result map[string]interface{} `json:"result"` + Success bool `json:"success"` + TxID ids.ID `json:"txId"` + Error string `json:"error"` +} + +func (r txResponse) String() string { + var result strings.Builder + if r.Success { + result.WriteString(fmt.Sprintf("✅ Transaction successful (txID: %s)\n", r.TxID)) + if r.Result != nil { + for key, value := range r.Result { + jsonValue, err := json.Marshal(value) + if err != nil { + jsonValue = []byte(fmt.Sprintf("%v", value)) + } + result.WriteString(fmt.Sprintf("%s: %s\n", key, string(jsonValue))) + } + } + } else { + result.WriteString(fmt.Sprintf("❌ Transaction failed (txID: %s): %s\n", r.TxID, r.Error)) + } + return strings.TrimSpace(result.String()) +} + +func init() { + txCmd.Flags().StringToString("data", nil, "Key-value pairs for the action data (e.g., key1=value1,key2=value2)") + rootCmd.AddCommand(txCmd) +} diff --git a/codec/hex.go b/codec/hex.go index 44d2e5d479..11bb929ece 100644 --- a/codec/hex.go +++ b/codec/hex.go @@ -13,6 +13,10 @@ func ToHex(b []byte) string { // LoadHex Converts hex encoded string into bytes. Returns // an error if key is invalid. func LoadHex(s string, expectedSize int) ([]byte, error) { + if len(s) >= 2 && s[:2] == "0x" { + s = s[2:] + } + bytes, err := hex.DecodeString(s) if err != nil { return nil, err diff --git a/codec/hex_test.go b/codec/hex_test.go index a610df8dc4..cfa0138bcd 100644 --- a/codec/hex_test.go +++ b/codec/hex_test.go @@ -29,3 +29,18 @@ func TestBytesHex(t *testing.T) { require.NoError(json.Unmarshal(jsonMarshalledBytes, &jsonUnmarshalledBytes)) require.Equal(b, []byte(jsonUnmarshalledBytes)) } + +func TestLoadHex(t *testing.T) { + require := require.New(t) + + var actual []byte + var err error + + actual, err = LoadHex("0x1234", 2) + require.NoError(err) + require.Equal([]byte{0x12, 0x34}, actual) + + actual, err = LoadHex("1234", 2) + require.NoError(err) + require.Equal([]byte{0x12, 0x34}, actual) +} diff --git a/consts/consts.go b/consts/consts.go index 377c8f8d21..96d6419d37 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -21,6 +21,7 @@ const ( // no more than 50 KiB of overhead but is likely much less) NetworkSizeLimit = 2_044_723 // 1.95 MiB + // FIXME: should use the standard math.MaxUint8, etc. MaxUint8 = ^uint8(0) MaxUint16 = ^uint16(0) MaxUint8Offset = 7 diff --git a/examples/morpheusvm/README.md b/examples/morpheusvm/README.md index bfe4fae0ca..d2441b1782 100644 --- a/examples/morpheusvm/README.md +++ b/examples/morpheusvm/README.md @@ -108,7 +108,7 @@ This should return the following JSON: } ``` -_By default, this allocates all funds on the network to `morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu`. The private +_By default, this allocates all funds on the network to `0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9`. The private key for this address is `0x323b1d8f4eed5f0da9da93071b034f2dce9d2d22692c172f3cb252a64ddfafd01b057de320297c29ad0c1f589ea216869cf1938d88c9fbd70d6748323dbf2fa7`. For convenience, this key has is also stored at `demo.pk`._ @@ -162,7 +162,7 @@ Next, you'll need to add the chains you created and the default key to the If the key is added corretcly, you'll see the following log: ``` database: .morpheus-cli -imported address: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu +imported address: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 ``` Next, you'll need to store the URL of the nodes running on your Subnet: @@ -211,10 +211,10 @@ If successful, the balance response should look like this: ``` database: .morpheus-cli 2024/09/09 10:52:49 [JOB 1] WAL file .morpheus-cli/000044.log with log number 000044 stopped reading at offset: 0; replayed 0 keys in 0 batches -address: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu +address: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 chainID: JkJpw8ZPExTushPYYN4C8f7RHxjDRX8MAGGUGAdRRPEC2M3fx uri: http://127.0.0.1:9650/ext/bc/morpheusvm -address: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu balance: 1000.000000000 RED +address: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 balance: 1000.000000000 RED ``` ### Generate Another Address @@ -243,7 +243,7 @@ database: .morpheus-cli 2024/09/09 10:53:51 [JOB 1] WAL file .morpheus-cli/000047.log with log number 000047 stopped reading at offset: 0; replayed 0 keys in 0 batches chainID: JkJpw8ZPExTushPYYN4C8f7RHxjDRX8MAGGUGAdRRPEC2M3fx stored keys: 2 -0) address: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu balance: 10000000000.000000000 RED +0) address: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 balance: 10000000000.000000000 RED 1) address: morpheus1q8pyshaqzx4q9stqdt88hyg22axjwrvl0w9wgczct5fnfev9gcnrsqwjdn0 balance: 0.000000000 RED set default key: 0 ``` @@ -285,7 +285,7 @@ select chainID: 0 uri: http://127.0.0.1:9650/ext/bc/morpheusvm watching for new blocks on 2mQy8Q9Af9dtZvVM8pKsh2rB3cT3QNLjghpet5Mm5db4N7Hwgk 👀 height:1 txs:1 units:440 root:WspVPrHNAwBcJRJPVwt7TW6WT4E74dN8DuD3WXueQTMt5FDdi -✅ sceRdaoqu2AAyLdHCdQkENZaXngGjRoc8nFdGyG8D9pCbTjbk actor: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu units: 440 summary (*actions.Transfer): [10.000000000 RED -> morpheus1q8rc050907hx39vfejpawjydmwe6uujw0njx9s6skzdpp3cm2he5s036p07] +✅ sceRdaoqu2AAyLdHCdQkENZaXngGjRoc8nFdGyG8D9pCbTjbk actor: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 units: 440 summary (*actions.Transfer): [10.000000000 RED -> morpheus1q8rc050907hx39vfejpawjydmwe6uujw0njx9s6skzdpp3cm2he5s036p07] ``` If you are running this on a local network, you may see that all blocks are empty. diff --git a/examples/morpheusvm/actions/transfer_test.go b/examples/morpheusvm/actions/transfer_test.go index e232797587..7625ee7bf4 100644 --- a/examples/morpheusvm/actions/transfer_test.go +++ b/examples/morpheusvm/actions/transfer_test.go @@ -32,14 +32,14 @@ func TestTransferAction(t *testing.T) { ExpectedErr: ErrOutputValueZero, }, { - Name: "InvalidAddress", + Name: "NonExistentAddress", Actor: codec.EmptyAddress, Action: &Transfer{ To: codec.EmptyAddress, Value: 1, }, State: chaintest.NewInMemoryStore(), - ExpectedErr: storage.ErrInvalidAddress, + ExpectedErr: storage.ErrInvalidBalance, }, { Name: "NotEnoughBalance", diff --git a/examples/morpheusvm/storage/storage.go b/examples/morpheusvm/storage/storage.go index 2566d919ab..b366a8cea7 100644 --- a/examples/morpheusvm/storage/storage.go +++ b/examples/morpheusvm/storage/storage.go @@ -147,7 +147,7 @@ func SubBalance( ) (uint64, error) { key, bal, ok, err := getBalance(ctx, mu, addr) if !ok { - return 0, ErrInvalidAddress + return 0, ErrInvalidBalance } if err != nil { return 0, err diff --git a/go.mod b/go.mod index a225904a11..2ef0060796 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/prometheus/client_golang v1.16.0 github.com/spf13/cobra v1.5.0 + github.com/spf13/viper v1.12.0 github.com/stretchr/testify v1.8.4 go.opentelemetry.io/otel v1.22.0 go.opentelemetry.io/otel/exporters/zipkin v1.11.2 @@ -123,7 +124,6 @@ require ( github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/spf13/viper v1.12.0 // indirect github.com/status-im/keycard-go v0.2.0 // indirect github.com/subosito/gotenv v1.3.0 // indirect github.com/supranational/blst v0.3.11 // indirect