diff --git a/abi/abi_test.go b/abi/abi_test.go index a91202a66f..f918ba0033 100644 --- a/abi/abi_test.go +++ b/abi/abi_test.go @@ -26,6 +26,7 @@ func TestNewABI(t *testing.T) { Outer{}, ActionWithOutput{}, FixedBytes{}, + Bools{}, }, []codec.Typed{ ActionOutput{}, }) diff --git a/abi/auto_marshal_abi_spec_test.go b/abi/auto_marshal_abi_spec_test.go index 3a84bd4451..4cbc744d34 100644 --- a/abi/auto_marshal_abi_spec_test.go +++ b/abi/auto_marshal_abi_spec_test.go @@ -60,6 +60,7 @@ func TestMarshalSpecs(t *testing.T) { {"strOnly", &MockObjectStringAndBytes{}}, {"outer", &Outer{}}, {"fixedBytes", &FixedBytes{}}, + {"bools", &Bools{}}, } for _, tc := range testCases { diff --git a/abi/dynamic/reflect_marshal.go b/abi/dynamic/reflect_marshal.go new file mode 100644 index 0000000000..90c07a1b3f --- /dev/null +++ b/abi/dynamic/reflect_marshal.go @@ -0,0 +1,170 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package dynamic + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + + "github.com/ava-labs/avalanchego/utils/wrappers" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/ava-labs/hypersdk/abi" + "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/consts" +) + +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 { + return nil, fmt.Errorf("marshalling %s: %w", typeName, ErrTypeNotFound) + } + + typeCache := make(map[string]reflect.Type) + + typ, err := getReflectType(typeName, inputABI, typeCache) + if err != nil { + return nil, fmt.Errorf("failed to get reflect type: %w", err) + } + + value := reflect.New(typ).Interface() + + if err := json.Unmarshal([]byte(jsonData), value); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON data: %w", err) + } + + writer := codec.NewWriter(0, consts.NetworkSizeLimit) + if err := codec.LinearCodec.MarshalInto(value, writer.Packer); err != nil { + return nil, fmt.Errorf("failed to marshal struct: %w", err) + } + + 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) + } + + typeCache := make(map[string]reflect.Type) + + typ, err := getReflectType(typeName, inputABI, typeCache) + if err != nil { + return "", fmt.Errorf("failed to get reflect type: %w", err) + } + + value := reflect.New(typ).Interface() + + packer := wrappers.Packer{ + Bytes: data, + MaxSize: consts.NetworkSizeLimit, + } + if err := codec.LinearCodec.UnmarshalFrom(&packer, value); err != nil { + return "", fmt.Errorf("failed to unmarshal data: %w", err) + } + + jsonData, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("failed to marshal struct to JSON: %w", err) + } + + return string(jsonData), nil +} + +// Matches fixed-size arrays like [32]uint8 +var fixedSizeArrayRegex = regexp.MustCompile(`^\[(\d+)\](.+)$`) + +func getReflectType(abiTypeName string, inputABI abi.ABI, typeCache map[string]reflect.Type) (reflect.Type, error) { + switch abiTypeName { + case "string": + return reflect.TypeOf(""), nil + case "uint8": + return reflect.TypeOf(uint8(0)), nil + case "uint16": + return reflect.TypeOf(uint16(0)), nil + case "uint32": + return reflect.TypeOf(uint32(0)), nil + case "uint64": + return reflect.TypeOf(uint64(0)), nil + case "int8": + return reflect.TypeOf(int8(0)), nil + case "int16": + return reflect.TypeOf(int16(0)), nil + case "int32": + return reflect.TypeOf(int32(0)), nil + case "int64": + return reflect.TypeOf(int64(0)), nil + case "Address": + return reflect.TypeOf(codec.Address{}), nil + default: + // golang slices + if strings.HasPrefix(abiTypeName, "[]") { + elemType, err := getReflectType(strings.TrimPrefix(abiTypeName, "[]"), inputABI, typeCache) + if err != nil { + return nil, err + } + return reflect.SliceOf(elemType), nil + } + + // golang arrays + + if match := fixedSizeArrayRegex.FindStringSubmatch(abiTypeName); match != nil { + sizeStr := match[1] + size, err := strconv.Atoi(sizeStr) + if err != nil { + return nil, fmt.Errorf("failed to convert size to int: %w", err) + } + elemType, err := getReflectType(match[2], inputABI, typeCache) + if err != nil { + return nil, err + } + return reflect.ArrayOf(size, elemType), nil + } + + // For custom types, recursively construct the struct type + if cachedType, ok := typeCache[abiTypeName]; ok { + return cachedType, nil + } + + abiType, ok := findABIType(inputABI, 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 { + return nil, err + } + fields[i] = reflect.StructField{ + Name: cases.Title(language.English).String(field.Name), + Type: fieldType, + Tag: reflect.StructTag(fmt.Sprintf(`serialize:"true" json:"%s"`, field.Name)), + } + } + + structType := reflect.StructOf(fields) + typeCache[abiTypeName] = structType + + 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 new file mode 100644 index 0000000000..23bc296b9a --- /dev/null +++ b/abi/dynamic/reflect_marshal_test.go @@ -0,0 +1,94 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package dynamic + +import ( + "encoding/hex" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/hypersdk/abi" +) + +func TestDynamicMarshal(t *testing.T) { + require := require.New(t) + + abiJSON := mustReadFile(t, "../testdata/abi.json") + var abi abi.ABI + + err := json.Unmarshal(abiJSON, &abi) + require.NoError(err) + + testCases := []struct { + name string + typeName string + }{ + {"empty", "MockObjectSingleNumber"}, + {"uint16", "MockObjectSingleNumber"}, + {"numbers", "MockObjectAllNumbers"}, + {"arrays", "MockObjectArrays"}, + {"transfer", "MockActionTransfer"}, + {"transferField", "MockActionWithTransfer"}, + {"transfersArray", "MockActionWithTransferArray"}, + {"strBytes", "MockObjectStringAndBytes"}, + {"strByteZero", "MockObjectStringAndBytes"}, + {"strBytesEmpty", "MockObjectStringAndBytes"}, + {"strOnly", "MockObjectStringAndBytes"}, + {"outer", "Outer"}, + {"fixedBytes", "FixedBytes"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Read the JSON data + jsonData := mustReadFile(t, "../testdata/"+tc.name+".json") + + objectBytes, err := Marshal(abi, tc.typeName, string(jsonData)) + require.NoError(err) + + // Compare with expected hex + expectedHex := string(mustReadFile(t, "../testdata/"+tc.name+".hex")) + expectedHex = strings.TrimSpace(expectedHex) + require.Equal(expectedHex, hex.EncodeToString(objectBytes)) + + unmarshaledJSON, err := Unmarshal(abi, tc.typeName, objectBytes) + require.NoError(err) + + // Compare with expected JSON + require.JSONEq(string(jsonData), unmarshaledJSON) + }) + } +} + +func TestDynamicMarshalErrors(t *testing.T) { + require := require.New(t) + + abiJSON := mustReadFile(t, "../testdata/abi.json") + var abi abi.ABI + + err := json.Unmarshal(abiJSON, &abi) + require.NoError(err) + + // Test malformed JSON + malformedJSON := `{"uint8": 42, "uint16": 1000, "uint32": 100000, "uint64": 10000000000, "int8": -42, "int16": -1000, "int32": -100000, "int64": -10000000000,` + _, err = Marshal(abi, "MockObjectAllNumbers", malformedJSON) + require.Contains(err.Error(), "unexpected end of JSON input") + + // Test wrong struct name + jsonData := mustReadFile(t, "../testdata/numbers.json") + _, err = Marshal(abi, "NonExistentObject", string(jsonData)) + require.ErrorIs(err, ErrTypeNotFound) +} + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + + content, err := os.ReadFile(path) + require.NoError(t, err) + return content +} diff --git a/abi/mockabi_test.go b/abi/mockabi_test.go index be59da4b2e..6a5355ec79 100644 --- a/abi/mockabi_test.go +++ b/abi/mockabi_test.go @@ -70,6 +70,12 @@ type FixedBytes struct { ThirtyTwoBytes [32]uint8 `serialize:"true" json:"thirtyTwoBytes"` } +type Bools struct { + Bool1 bool `serialize:"true" json:"bool1"` + Bool2 bool `serialize:"true" json:"bool2"` + BoolArray []bool `serialize:"true" json:"boolArray"` +} + type ActionOutput struct { Field1 uint16 `serialize:"true" json:"field1"` } @@ -114,6 +120,10 @@ func (FixedBytes) GetTypeID() uint8 { return 9 } +func (Bools) GetTypeID() uint8 { + return 10 +} + func (ActionOutput) GetTypeID() uint8 { return 0 } diff --git a/abi/testdata/abi.hash.hex b/abi/testdata/abi.hash.hex index 3fb15dc199..ebbaaf8b49 100644 --- a/abi/testdata/abi.hash.hex +++ b/abi/testdata/abi.hash.hex @@ -1 +1 @@ -075005590e3e3e39dffbf8e5a6d77559b8a33e621078782571695f60938126d3 +3b634237434bc35076e790b52986d735f30d582e9b7b94ad357d91b33072e284 diff --git a/abi/testdata/abi.json b/abi/testdata/abi.json index 50ee4382d6..673aefdaf2 100644 --- a/abi/testdata/abi.json +++ b/abi/testdata/abi.json @@ -39,6 +39,10 @@ { "id": 9, "name": "FixedBytes" + }, + { + "id": 10, + "name": "Bools" } ], "outputs": [ @@ -210,15 +214,16 @@ ] }, { + "name": "ActionWithOutput", "fields": [ { "name": "field1", "type": "uint8" } - ], - "name": "ActionWithOutput" + ] }, { + "name": "FixedBytes", "fields": [ { "name": "twoBytes", @@ -228,17 +233,33 @@ "name": "thirtyTwoBytes", "type": "[32]uint8" } - ], - "name": "FixedBytes" + ] + }, + { + "name": "Bools", + "fields": [ + { + "name": "bool1", + "type": "bool" + }, + { + "name": "bool2", + "type": "bool" + }, + { + "name": "boolArray", + "type": "[]bool" + } + ] }, { + "name": "ActionOutput", "fields": [ { "name": "field1", "type": "uint16" } - ], - "name": "ActionOutput" + ] } ] } diff --git a/abi/testdata/bools.hex b/abi/testdata/bools.hex new file mode 100644 index 0000000000..3a2b3cd07a --- /dev/null +++ b/abi/testdata/bools.hex @@ -0,0 +1 @@ +000100000003010001 diff --git a/abi/testdata/bools.json b/abi/testdata/bools.json new file mode 100644 index 0000000000..8102f3d71c --- /dev/null +++ b/abi/testdata/bools.json @@ -0,0 +1,9 @@ +{ + "bool1": false, + "bool2": true, + "boolArray": [ + true, + false, + true + ] +} diff --git a/abi/testdata/transfer.json b/abi/testdata/transfer.json index c099bc7d5e..e350cf98ab 100644 --- a/abi/testdata/transfer.json +++ b/abi/testdata/transfer.json @@ -1,5 +1,5 @@ { - "to": "0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", + "to": "0x0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", "value": 1000, "memo": "aGk=" } diff --git a/abi/testdata/transferField.json b/abi/testdata/transferField.json index 549b53fbd6..fb5833315f 100644 --- a/abi/testdata/transferField.json +++ b/abi/testdata/transferField.json @@ -1,6 +1,6 @@ { "transfer": { - "to": "0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", + "to": "0x0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", "value": 1000, "memo": "aGk=" } diff --git a/abi/testdata/transfersArray.json b/abi/testdata/transfersArray.json index d4b3989b33..7ce4ab7ccf 100644 --- a/abi/testdata/transfersArray.json +++ b/abi/testdata/transfersArray.json @@ -1,12 +1,12 @@ { "transfers": [ { - "to": "0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", + "to": "0x0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", "value": 1000, "memo": "aGk=" }, { - "to": "0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", + "to": "0x0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", "value": 1000, "memo": "aGk=" } diff --git a/api/dependencies.go b/api/dependencies.go index f89258528d..ee9856ea4d 100644 --- a/api/dependencies.go +++ b/api/dependencies.go @@ -12,6 +12,7 @@ import ( "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/fees" "github.com/ava-labs/hypersdk/genesis" "github.com/ava-labs/hypersdk/state" @@ -24,9 +25,9 @@ type VM interface { SubnetID() ids.ID Tracer() trace.Tracer Logger() logging.Logger - ActionRegistry() chain.ActionRegistry - OutputRegistry() chain.OutputRegistry - AuthRegistry() chain.AuthRegistry + ActionCodec() *codec.TypeParser[chain.Action] + OutputCodec() *codec.TypeParser[codec.Typed] + AuthCodec() *codec.TypeParser[chain.Auth] Rules(t int64) chain.Rules Submit( ctx context.Context, diff --git a/api/jsonrpc/client.go b/api/jsonrpc/client.go index 54e89d9f2e..ea3b29acaa 100644 --- a/api/jsonrpc/client.go +++ b/api/jsonrpc/client.go @@ -169,9 +169,9 @@ func (cli *JSONRPCClient) GenerateTransactionManual( } // Build transaction - actionRegistry, authRegistry := parser.ActionRegistry(), parser.AuthRegistry() - tx := chain.NewTx(base, actions) - tx, err := tx.Sign(authFactory, actionRegistry, authRegistry) + actionCodec, authCodec := parser.ActionCodec(), parser.AuthCodec() + unsignedTx := chain.NewTxData(base, actions) + tx, err := unsignedTx.Sign(authFactory, actionCodec, authCodec) if err != nil { return nil, nil, fmt.Errorf("%w: failed to sign transaction", err) } @@ -194,21 +194,25 @@ func (cli *JSONRPCClient) GetABI(ctx context.Context) (abi.ABI, error) { return resp.ABI, err } -func (cli *JSONRPCClient) Execute(ctx context.Context, actor codec.Address, action chain.Action) ([]byte, error) { - actionBytes, err := chain.MarshalTyped(action) - if err != nil { - return nil, fmt.Errorf("failed to marshal action: %w", 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) } args := &ExecuteActionArgs{ - Actor: actor, - Action: actionBytes, + Actor: actor, + Actions: actionsMarshaled, } resp := new(ExecuteActionReply) - err = cli.requester.SendRequest( + err := cli.requester.SendRequest( ctx, - "executeAction", + "executeActions", args, resp, ) @@ -219,7 +223,7 @@ func (cli *JSONRPCClient) Execute(ctx context.Context, actor codec.Address, acti return nil, fmt.Errorf("failed to execute action: %s", resp.Error) } - return resp.Output, nil + return resp.Outputs, nil } func Wait(ctx context.Context, interval time.Duration, check func(ctx context.Context) (bool, error)) error { @@ -235,3 +239,30 @@ func Wait(ctx context.Context, interval time.Duration, check func(ctx context.Co } return ctx.Err() } + +func (cli *JSONRPCClient) SimulateActions(ctx context.Context, actions chain.Actions, actor codec.Address) ([]SimulateActionResult, error) { + args := &SimulatActionsArgs{ + Actor: actor, + } + + for _, action := range actions { + marshaledAction, err := chain.MarshalTyped(action) + if err != nil { + return nil, err + } + args.Actions = append(args.Actions, marshaledAction) + } + + resp := new(SimulateActionsReply) + err := cli.requester.SendRequest( + ctx, + "simulateActions", + args, + resp, + ) + if err != nil { + return nil, err + } + + return resp.ActionResults, nil +} diff --git a/api/jsonrpc/server.go b/api/jsonrpc/server.go index 8515c03a9a..361ca9513d 100644 --- a/api/jsonrpc/server.go +++ b/api/jsonrpc/server.go @@ -18,6 +18,7 @@ import ( "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/consts" "github.com/ava-labs/hypersdk/fees" + "github.com/ava-labs/hypersdk/state" "github.com/ava-labs/hypersdk/state/tstate" ) @@ -25,8 +26,15 @@ const ( Endpoint = "/coreapi" ) +var errNoActionsToExecute = errors.New("no actions to execute") + var _ api.HandlerFactory[api.VM] = (*JSONRPCServerFactory)(nil) +var ( + errSimulateZeroActions = errors.New("simulateAction expects at least a single action, none found") + errTransactionExtraBytes = errors.New("transaction has extra bytes") +) + type JSONRPCServerFactory struct{} func (JSONRPCServerFactory) New(vm api.VM) (api.Handler, error) { @@ -88,16 +96,16 @@ func (j *JSONRPCServer) SubmitTx( ctx, span := j.vm.Tracer().Start(req.Context(), "JSONRPCServer.SubmitTx") defer span.End() - actionRegistry, authRegistry := j.vm.ActionRegistry(), j.vm.AuthRegistry() + actionCodec, authCodec := j.vm.ActionCodec(), j.vm.AuthCodec() rtx := codec.NewReader(args.Tx, consts.NetworkSizeLimit) // will likely be much smaller than this - tx, err := chain.UnmarshalTx(rtx, actionRegistry, authRegistry) + tx, err := chain.UnmarshalTx(rtx, actionCodec, authCodec) if err != nil { return fmt.Errorf("%w: unable to unmarshal on public service", err) } if !rtx.Empty() { - return errors.New("tx has extra bytes") + return errTransactionExtraBytes } - if err := tx.Verify(ctx); err != nil { + if err := tx.VerifyAuth(ctx); err != nil { return err } txID := tx.ID() @@ -146,9 +154,8 @@ type GetABIReply struct { } func (j *JSONRPCServer) GetABI(_ *http.Request, _ *GetABIArgs, reply *GetABIReply) error { - actionRegistry, outputRegistry := j.vm.ActionRegistry(), j.vm.OutputRegistry() - // Must dereference aliased type to call GetRegisteredTypes - vmABI, err := abi.NewABI((*actionRegistry).GetRegisteredTypes(), (*outputRegistry).GetRegisteredTypes()) + actionCodec, outputCodec := j.vm.ActionCodec(), j.vm.OutputCodec() + vmABI, err := abi.NewABI(actionCodec.GetRegisteredTypes(), outputCodec.GetRegisteredTypes()) if err != nil { return err } @@ -157,16 +164,16 @@ func (j *JSONRPCServer) GetABI(_ *http.Request, _ *GetABIArgs, reply *GetABIRepl } type ExecuteActionArgs struct { - Actor codec.Address `json:"actor"` - Action []byte `json:"action"` + Actor codec.Address `json:"actor"` + Actions [][]byte `json:"actions"` } type ExecuteActionReply struct { - Output []byte `json:"output"` - Error string `json:"error"` + Outputs [][]byte `json:"outputs"` + Error string `json:"error"` } -func (j *JSONRPCServer) Execute( +func (j *JSONRPCServer) ExecuteActions( req *http.Request, args *ExecuteActionArgs, reply *ExecuteActionReply, @@ -174,59 +181,140 @@ func (j *JSONRPCServer) Execute( ctx, span := j.vm.Tracer().Start(req.Context(), "JSONRPCServer.ExecuteAction") defer span.End() - actionRegistry := j.vm.ActionRegistry() - action, err := (*actionRegistry).Unmarshal(codec.NewReader(args.Action, len(args.Action))) - if err != nil { - return fmt.Errorf("failed to unmashal action: %w", err) + actionCodec := j.vm.ActionCodec() + if len(args.Actions) == 0 { + return errNoActionsToExecute + } + if maxActionsPerTx := int(j.vm.Rules(time.Now().Unix()).GetMaxActionsPerTx()); len(args.Actions) > maxActionsPerTx { + return fmt.Errorf("exceeded max actions per simulation: %d", maxActionsPerTx) + } + actions := make([]chain.Action, 0, len(args.Actions)) + for _, action := range args.Actions { + action, err := actionCodec.Unmarshal(codec.NewReader(action, len(action))) + if err != nil { + return fmt.Errorf("failed to unmashal action: %w", err) + } + actions = append(actions, action) } now := time.Now().UnixMilli() - // Get expected state keys - stateKeysWithPermissions := action.StateKeys(args.Actor) + storage := make(map[string][]byte) + ts := tstate.New(1) - // flatten the map to a slice of keys - storageKeysToRead := make([][]byte, 0) - for key := range stateKeysWithPermissions { - storageKeysToRead = append(storageKeysToRead, []byte(key)) - } + for actionIndex, action := range actions { + // Get expected state keys + stateKeysWithPermissions := action.StateKeys(args.Actor) - storage := make(map[string][]byte) - values, errs := j.vm.ReadState(ctx, storageKeysToRead) - for _, err := range errs { - if err != nil && !errors.Is(err, database.ErrNotFound) { - return fmt.Errorf("failed to read state: %w", err) + // flatten the map to a slice of keys + storageKeysToRead := make([][]byte, 0, len(stateKeysWithPermissions)) + for key := range stateKeysWithPermissions { + storageKeysToRead = append(storageKeysToRead, []byte(key)) } - } - for i, value := range values { - if value == nil { - continue + + values, errs := j.vm.ReadState(ctx, storageKeysToRead) + for _, err := range errs { + if err != nil && !errors.Is(err, database.ErrNotFound) { + return fmt.Errorf("failed to read state: %w", err) + } + } + for i, value := range values { + if value == nil { + continue + } + storage[string(storageKeysToRead[i])] = value } - storage[string(storageKeysToRead[i])] = value - } - ts := tstate.New(1) - tsv := ts.NewView(stateKeysWithPermissions, storage) - - output, err := action.Execute( - ctx, - j.vm.Rules(now), - tsv, - now, - args.Actor, - ids.Empty, - ) - if err != nil { - reply.Error = fmt.Sprintf("failed to execute action: %s", err) - return nil + tsv := ts.NewView(stateKeysWithPermissions, storage) + + output, err := action.Execute( + ctx, + j.vm.Rules(now), + tsv, + now, + args.Actor, + chain.CreateActionID(ids.Empty, uint8(actionIndex)), + ) + if err != nil { + reply.Error = fmt.Sprintf("failed to execute action: %s", err) + return nil + } + + tsv.Commit() + + encodedOutput, err := chain.MarshalTyped(output) + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + + reply.Outputs = append(reply.Outputs, encodedOutput) } + return nil +} + +type SimulatActionsArgs struct { + Actions []codec.Bytes `json:"actions"` + Actor codec.Address `json:"actor"` +} + +type SimulateActionResult struct { + Output codec.Bytes `json:"output"` + StateKeys state.Keys `json:"stateKeys"` +} - encodedOutput, err := chain.MarshalTyped(output) +type SimulateActionsReply struct { + ActionResults []SimulateActionResult `json:"actionresults"` +} + +func (j *JSONRPCServer) SimulateActions( + req *http.Request, + args *SimulatActionsArgs, + reply *SimulateActionsReply, +) error { + ctx, span := j.vm.Tracer().Start(req.Context(), "JSONRPCServer.SimulateActions") + defer span.End() + + actionRegistry := j.vm.ActionCodec() + var actions chain.Actions + for _, actionBytes := range args.Actions { + actionsReader := codec.NewReader(actionBytes, len(actionBytes)) + action, err := (*actionRegistry).Unmarshal(actionsReader) + if err != nil { + return err + } + if !actionsReader.Empty() { + return errTransactionExtraBytes + } + actions = append(actions, action) + } + if len(actions) == 0 { + return errSimulateZeroActions + } + currentState, err := j.vm.ImmutableState(ctx) if err != nil { - return fmt.Errorf("failed to marshal output: %w", err) + return err } - reply.Output = encodedOutput - + currentTime := time.Now().UnixMilli() + for _, action := range actions { + recorder := state.NewRecorder(currentState) + actionOutput, err := action.Execute(ctx, j.vm.Rules(currentTime), recorder, currentTime, args.Actor, ids.Empty) + + var actionResult SimulateActionResult + if actionOutput == nil { + actionResult.Output = []byte{} + } else { + actionResult.Output, err = chain.MarshalTyped(actionOutput) + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + } + if err != nil { + return err + } + actionResult.StateKeys = recorder.GetStateKeys() + reply.ActionResults = append(reply.ActionResults, actionResult) + currentState = recorder + } return nil } diff --git a/api/ws/server.go b/api/ws/server.go index 2214ec9720..b98103659d 100644 --- a/api/ws/server.go +++ b/api/ws/server.go @@ -55,13 +55,13 @@ func OptionFunc(v *vm.VM, config Config) error { return nil } - actionRegistry, authRegistry := v.ActionRegistry(), v.AuthRegistry() + actionCodec, authCodec := v.ActionCodec(), v.AuthCodec() server, handler := NewWebSocketServer( v, v.Logger(), v.Tracer(), - actionRegistry, - authRegistry, + actionCodec, + authCodec, config.MaxPendingMessages, ) @@ -103,11 +103,11 @@ func (w WebSocketServerFactory) New(api.VM) (api.Handler, error) { } type WebSocketServer struct { - vm api.VM - logger logging.Logger - tracer trace.Tracer - actionRegistry chain.ActionRegistry - authRegistry chain.AuthRegistry + vm api.VM + logger logging.Logger + tracer trace.Tracer + actionCodec *codec.TypeParser[chain.Action] + authCodec *codec.TypeParser[chain.Auth] s *pubsub.Server @@ -122,16 +122,16 @@ func NewWebSocketServer( vm api.VM, log logging.Logger, tracer trace.Tracer, - actionRegistry chain.ActionRegistry, - authRegistry chain.AuthRegistry, + actionCodec *codec.TypeParser[chain.Action], + authCodec *codec.TypeParser[chain.Auth], maxPendingMessages int, ) (*WebSocketServer, *pubsub.Server) { w := &WebSocketServer{ vm: vm, logger: log, tracer: tracer, - actionRegistry: actionRegistry, - authRegistry: authRegistry, + actionCodec: actionCodec, + authCodec: authCodec, blockListeners: pubsub.NewConnections(), txListeners: map[ids.ID]*pubsub.Connections{}, expiringTxs: emap.NewEMap[*chain.Transaction](), @@ -253,7 +253,7 @@ func (w *WebSocketServer) MessageCallback() pubsub.Callback { msgBytes = msgBytes[1:] // Unmarshal TX p := codec.NewReader(msgBytes, consts.NetworkSizeLimit) // will likely be much smaller - tx, err := chain.UnmarshalTx(p, w.actionRegistry, w.authRegistry) + tx, err := chain.UnmarshalTx(p, w.actionCodec, w.authCodec) if err != nil { w.logger.Error("failed to unmarshal tx", zap.Int("len", len(msgBytes)), @@ -264,7 +264,7 @@ func (w *WebSocketServer) MessageCallback() pubsub.Callback { // Verify tx if w.vm.GetVerifyAuth() { - if err := tx.Verify(ctx); err != nil { + if err := tx.VerifyAuth(ctx); err != nil { w.logger.Error("failed to verify sig", zap.Error(err), ) diff --git a/auth/consts.go b/auth/consts.go index 0f88ee726b..2548a4e69e 100644 --- a/auth/consts.go +++ b/auth/consts.go @@ -11,6 +11,10 @@ const ( ED25519ID uint8 = 0 SECP256R1ID uint8 = 1 BLSID uint8 = 2 + + ED25519Key = "ed25519" + Secp256r1Key = "secp256r1" + BLSKey = "bls" ) func Engines() map[uint8]vm.AuthEngine { diff --git a/auth/utils.go b/auth/utils.go new file mode 100644 index 0000000000..baed04b64c --- /dev/null +++ b/auth/utils.go @@ -0,0 +1,44 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package auth + +import ( + "errors" + + "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/crypto/bls" + "github.com/ava-labs/hypersdk/crypto/ed25519" + "github.com/ava-labs/hypersdk/crypto/secp256r1" +) + +var ErrInvalidKeyType = errors.New("invalid key type") + +// Used for testing & CLI purposes +type PrivateKey struct { + Address codec.Address + // Bytes is the raw private key bytes + Bytes []byte +} + +// GetFactory returns the [chain.AuthFactory] for a given private key. +// +// A [chain.AuthFactory] signs transactions and provides a unit estimate +// for using a given private key (needed to estimate fees for a transaction). +func GetFactory(pk *PrivateKey) (chain.AuthFactory, error) { + switch pk.Address[0] { + case ED25519ID: + return NewED25519Factory(ed25519.PrivateKey(pk.Bytes)), nil + case SECP256R1ID: + return NewSECP256R1Factory(secp256r1.PrivateKey(pk.Bytes)), nil + case BLSID: + p, err := bls.PrivateKeyFromBytes(pk.Bytes) + if err != nil { + return nil, err + } + return NewBLSFactory(p), nil + default: + return nil, ErrInvalidKeyType + } +} diff --git a/chain/block.go b/chain/block.go index 8aca3d971e..bab418c43a 100644 --- a/chain/block.go +++ b/chain/block.go @@ -115,11 +115,11 @@ func UnmarshalBlock(raw []byte, parser Parser) (*StatelessBlock, error) { // Parse transactions txCount := p.UnpackInt(false) // can produce empty blocks - actionRegistry, authRegistry := parser.ActionRegistry(), parser.AuthRegistry() + actionCodec, authCodec := parser.ActionCodec(), parser.AuthCodec() b.Txs = []*Transaction{} // don't preallocate all to avoid DoS b.authCounts = map[uint8]int{} for i := uint32(0); i < txCount; i++ { - tx, err := UnmarshalTx(p, actionRegistry, authRegistry) + tx, err := UnmarshalTx(p, actionCodec, authCodec) if err != nil { return nil, err } @@ -153,7 +153,7 @@ func NewGenesisBlock(root ids.ID) *StatelessBlock { } } -// Stateless is defined separately from "Block" +// StatefulBlock is defined separately from "StatelessBlock" // in case external packages need to use the stateless block // without mocking VM or parent block type StatefulBlock struct { @@ -200,7 +200,7 @@ func ParseBlock( return nil, err } // Not guaranteed that a parsed block is verified - return ParseStatelessBlock(ctx, blk, source, accepted, vm) + return ParseStatefulBlock(ctx, blk, source, accepted, vm) } // populateTxs is only called on blocks we did not build @@ -246,14 +246,14 @@ func (b *StatefulBlock) populateTxs(ctx context.Context) error { return nil } -func ParseStatelessBlock( +func ParseStatefulBlock( ctx context.Context, blk *StatelessBlock, source []byte, accepted bool, vm VM, ) (*StatefulBlock, error) { - ctx, span := vm.Tracer().Start(ctx, "chain.ParseStatelessBlock") + ctx, span := vm.Tracer().Start(ctx, "chain.ParseStatefulBlock") defer span.End() // Perform basic correctness checks before doing any expensive work diff --git a/chain/chaintest/test_parser.go b/chain/chaintest/test_parser.go index a4fe7e5a72..c6b2e37c4b 100644 --- a/chain/chaintest/test_parser.go +++ b/chain/chaintest/test_parser.go @@ -10,32 +10,32 @@ import ( ) type Parser struct { - rules genesis.RuleFactory - actionRegistry chain.ActionRegistry - authRegistry chain.AuthRegistry - outputRegistry chain.OutputRegistry + rules genesis.RuleFactory + actionCodec *codec.TypeParser[chain.Action] + authCodec *codec.TypeParser[chain.Auth] + outputCodec *codec.TypeParser[codec.Typed] } func NewParser( ruleFactory genesis.RuleFactory, - actionRegistry chain.ActionRegistry, - authRegistry chain.AuthRegistry, - outputRegistry chain.OutputRegistry, + actionCodec *codec.TypeParser[chain.Action], + authCodec *codec.TypeParser[chain.Auth], + outputCodec *codec.TypeParser[codec.Typed], ) *Parser { return &Parser{ - rules: ruleFactory, - actionRegistry: actionRegistry, - authRegistry: authRegistry, - outputRegistry: outputRegistry, + rules: ruleFactory, + actionCodec: actionCodec, + authCodec: authCodec, + outputCodec: outputCodec, } } func NewEmptyParser() *Parser { return &Parser{ - rules: &genesis.ImmutableRuleFactory{Rules: genesis.NewDefaultRules()}, - actionRegistry: codec.NewTypeParser[chain.Action](), - authRegistry: codec.NewTypeParser[chain.Auth](), - outputRegistry: codec.NewTypeParser[codec.Typed](), + rules: &genesis.ImmutableRuleFactory{Rules: genesis.NewDefaultRules()}, + actionCodec: codec.NewTypeParser[chain.Action](), + authCodec: codec.NewTypeParser[chain.Auth](), + outputCodec: codec.NewTypeParser[codec.Typed](), } } @@ -43,14 +43,14 @@ func (p *Parser) Rules(t int64) chain.Rules { return p.rules.GetRules(t) } -func (p *Parser) ActionRegistry() chain.ActionRegistry { - return p.actionRegistry +func (p *Parser) ActionCodec() *codec.TypeParser[chain.Action] { + return p.actionCodec } -func (p *Parser) AuthRegistry() chain.AuthRegistry { - return p.authRegistry +func (p *Parser) AuthCodec() *codec.TypeParser[chain.Auth] { + return p.authCodec } -func (p *Parser) OutputRegistry() chain.OutputRegistry { - return p.outputRegistry +func (p *Parser) OutputCodec() *codec.TypeParser[codec.Typed] { + return p.outputCodec } diff --git a/chain/dependencies.go b/chain/dependencies.go index 90143ee0d9..280671d8e9 100644 --- a/chain/dependencies.go +++ b/chain/dependencies.go @@ -21,17 +21,11 @@ import ( "github.com/ava-labs/hypersdk/state" ) -type ( - ActionRegistry *codec.TypeParser[Action] - OutputRegistry *codec.TypeParser[codec.Typed] - AuthRegistry *codec.TypeParser[Auth] -) - type Parser interface { Rules(int64) Rules - ActionRegistry() ActionRegistry - OutputRegistry() OutputRegistry - AuthRegistry() AuthRegistry + ActionCodec() *codec.TypeParser[Action] + OutputCodec() *codec.TypeParser[codec.Typed] + AuthCodec() *codec.TypeParser[Auth] } type Metrics interface { @@ -287,9 +281,3 @@ type AuthFactory interface { MaxUnits() (bandwidth uint64, compute uint64) Address() codec.Address } - -type Registry interface { - ActionRegistry() ActionRegistry - AuthRegistry() AuthRegistry - OutputRegistry() OutputRegistry -} diff --git a/chain/registry.go b/chain/registry.go new file mode 100644 index 0000000000..c67d6291af --- /dev/null +++ b/chain/registry.go @@ -0,0 +1,38 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package chain + +import "github.com/ava-labs/hypersdk/codec" + +type Registry interface { + ActionRegistry() *codec.TypeParser[Action] + AuthRegistry() *codec.TypeParser[Auth] + OutputRegistry() *codec.TypeParser[codec.Typed] +} + +type registry struct { + actionRegistry *codec.TypeParser[Action] + authRegistry *codec.TypeParser[Auth] + outputRegistry *codec.TypeParser[codec.Typed] +} + +func NewRegistry(action *codec.TypeParser[Action], auth *codec.TypeParser[Auth], output *codec.TypeParser[codec.Typed]) Registry { + return ®istry{ + actionRegistry: action, + authRegistry: auth, + outputRegistry: output, + } +} + +func (r *registry) ActionRegistry() *codec.TypeParser[Action] { + return r.actionRegistry +} + +func (r *registry) AuthRegistry() *codec.TypeParser[Auth] { + return r.authRegistry +} + +func (r *registry) OutputRegistry() *codec.TypeParser[codec.Typed] { + return r.outputRegistry +} diff --git a/chain/transaction.go b/chain/transaction.go index b609db6a2a..1bb3eb1480 100644 --- a/chain/transaction.go +++ b/chain/transaction.go @@ -28,28 +28,23 @@ var ( _ mempool.Item = (*Transaction)(nil) ) -type Transaction struct { +type TransactionData struct { Base *Base `json:"base"` Actions Actions `json:"actions"` - Auth Auth `json:"auth"` unsignedBytes []byte - bytes []byte - size int - id ids.ID - stateKeys state.Keys } -func NewTx(base *Base, actions Actions) *Transaction { - return &Transaction{ +func NewTxData(base *Base, actions Actions) *TransactionData { + return &TransactionData{ Base: base, Actions: actions, } } -// UnsignedBytes returns the byte slice representation of the unsigned tx -func (t *Transaction) UnsignedBytes() ([]byte, error) { +// UnsignedBytes returns the byte slice representation of the tx +func (t *TransactionData) UnsignedBytes() ([]byte, error) { if len(t.unsignedBytes) > 0 { return t.unsignedBytes, nil } @@ -62,19 +57,19 @@ func (t *Transaction) UnsignedBytes() ([]byte, error) { size += actionsSize p := codec.NewWriter(size, consts.NetworkSizeLimit) - if err := t.marshal(p, false); err != nil { + if err := t.marshal(p); err != nil { return nil, err } - - return p.Bytes(), p.Err() + t.unsignedBytes = p.Bytes() + return t.unsignedBytes, p.Err() } // Sign returns a new signed transaction with the unsigned tx copied from // the original and a signature provided by the authFactory -func (t *Transaction) Sign( +func (t *TransactionData) Sign( factory AuthFactory, - actionRegistry ActionRegistry, - authRegistry AuthRegistry, + actionCodec *codec.TypeParser[Action], + authCodec *codec.TypeParser[Auth], ) (*Transaction, error) { msg, err := t.UnsignedBytes() if err != nil { @@ -86,9 +81,11 @@ func (t *Transaction) Sign( } signedTransaction := Transaction{ - Base: t.Base, - Actions: t.Actions, - Auth: auth, + TransactionData: TransactionData{ + Base: t.Base, + Actions: t.Actions, + }, + Auth: auth, } // Ensure transaction is fully initialized and correct by reloading it from @@ -102,17 +99,61 @@ func (t *Transaction) Sign( return nil, err } p = codec.NewReader(p.Bytes(), consts.MaxInt) - return UnmarshalTx(p, actionRegistry, authRegistry) + return UnmarshalTx(p, actionCodec, authCodec) } -// Verify that the transaction was signed correctly. -func (t *Transaction) Verify(ctx context.Context) error { - msg, err := t.UnsignedBytes() - if err != nil { - // Should never occur because populated during unmarshal - return err +func (t *TransactionData) Expiry() int64 { return t.Base.Timestamp } + +func (t *TransactionData) MaxFee() uint64 { return t.Base.MaxFee } + +func (t *TransactionData) Marshal(p *codec.Packer) error { + if len(t.unsignedBytes) > 0 { + p.PackFixedBytes(t.unsignedBytes) + return p.Err() } - return t.Auth.Verify(ctx, msg) + return t.marshal(p) +} + +func (t *TransactionData) marshal(p *codec.Packer) error { + t.Base.Marshal(p) + return t.Actions.marshalInto(p) +} + +type Actions []Action + +func (a Actions) Size() (int, error) { + var size int + for _, action := range a { + actionSize, err := GetSize(action) + if err != nil { + return 0, err + } + size += consts.ByteLen + actionSize + } + return size, nil +} + +func (a Actions) marshalInto(p *codec.Packer) error { + p.PackByte(uint8(len(a))) + for _, action := range a { + p.PackByte(action.GetTypeID()) + err := marshalInto(action, p) + if err != nil { + return err + } + } + return nil +} + +type Transaction struct { + TransactionData + + Auth Auth `json:"auth"` + + bytes []byte + size int + id ids.ID + stateKeys state.Keys } func (t *Transaction) Bytes() []byte { return t.bytes } @@ -121,10 +162,6 @@ func (t *Transaction) Size() int { return t.size } func (t *Transaction) ID() ids.ID { return t.id } -func (t *Transaction) Expiry() int64 { return t.Base.Timestamp } - -func (t *Transaction) MaxFee() uint64 { return t.Base.MaxFee } - func (t *Transaction) StateKeys(sm StateManager) (state.Keys, error) { if t.stateKeys != nil { return t.stateKeys, nil @@ -150,9 +187,6 @@ func (t *Transaction) StateKeys(sm StateManager) (state.Keys, error) { return stateKeys, nil } -// Sponsor is the [codec.Address] that pays fees for this transaction. -func (t *Transaction) Sponsor() codec.Address { return t.Auth.Sponsor() } - // Units is charged whether or not a transaction is successful. func (t *Transaction) Units(sm StateManager, r Rules) (fees.Dimensions, error) { // Calculate compute usage @@ -204,81 +238,10 @@ func (t *Transaction) Units(sm StateManager, r Rules) (fees.Dimensions, error) { return fees.Dimensions{uint64(t.Size()), maxComputeUnits, reads, allocates, writes}, nil } -// EstimateUnits provides a pessimistic estimate (some key accesses may be duplicates) of the cost -// to execute a transaction. -// -// This is typically used during transaction construction. -func EstimateUnits(r Rules, actions Actions, authFactory AuthFactory) (fees.Dimensions, error) { - var ( - bandwidth = uint64(BaseSize) - stateKeysMaxChunks = []uint16{} // TODO: preallocate - computeOp = math.NewUint64Operator(r.GetBaseComputeUnits()) - readsOp = math.NewUint64Operator(0) - allocatesOp = math.NewUint64Operator(0) - writesOp = math.NewUint64Operator(0) - ) - - // Calculate over action/auth - bandwidth += consts.Uint8Len - for _, action := range actions { - actionSize, err := GetSize(action) - if err != nil { - return fees.Dimensions{}, err - } - - actor := authFactory.Address() - stateKeys := action.StateKeys(actor) - actionStateKeysMaxChunks, ok := stateKeys.ChunkSizes() - if !ok { - return fees.Dimensions{}, ErrInvalidKeyValue - } - bandwidth += consts.ByteLen + uint64(actionSize) - stateKeysMaxChunks = append(stateKeysMaxChunks, actionStateKeysMaxChunks...) - computeOp.Add(action.ComputeUnits(r)) - } - authBandwidth, authCompute := authFactory.MaxUnits() - bandwidth += consts.ByteLen + authBandwidth - sponsorStateKeyMaxChunks := r.GetSponsorStateKeysMaxChunks() - stateKeysMaxChunks = append(stateKeysMaxChunks, sponsorStateKeyMaxChunks...) - computeOp.Add(authCompute) - - // Estimate compute costs - compute, err := computeOp.Value() - if err != nil { - return fees.Dimensions{}, err - } - - // Estimate storage costs - for _, maxChunks := range stateKeysMaxChunks { - // Compute key costs - readsOp.Add(r.GetStorageKeyReadUnits()) - allocatesOp.Add(r.GetStorageKeyAllocateUnits()) - writesOp.Add(r.GetStorageKeyWriteUnits()) - - // Compute value costs - readsOp.MulAdd(uint64(maxChunks), r.GetStorageValueReadUnits()) - allocatesOp.MulAdd(uint64(maxChunks), r.GetStorageValueAllocateUnits()) - writesOp.MulAdd(uint64(maxChunks), r.GetStorageValueWriteUnits()) - } - reads, err := readsOp.Value() - if err != nil { - return fees.Dimensions{}, err - } - allocates, err := allocatesOp.Value() - if err != nil { - return fees.Dimensions{}, err - } - writes, err := writesOp.Value() - if err != nil { - return fees.Dimensions{}, err - } - return fees.Dimensions{bandwidth, compute, reads, allocates, writes}, nil -} - func (t *Transaction) PreExecute( ctx context.Context, feeManager *internalfees.Manager, - s StateManager, + sm StateManager, r Rules, im state.Immutable, timestamp int64, @@ -305,7 +268,7 @@ func (t *Transaction) PreExecute( if end >= 0 && timestamp > end { return ErrAuthNotActivated } - units, err := t.Units(s, r) + units, err := t.Units(sm, r) if err != nil { return err } @@ -313,7 +276,7 @@ func (t *Transaction) PreExecute( if err != nil { return err } - return s.CanDeduct(ctx, t.Auth.Sponsor(), im, fee) + return sm.CanDeduct(ctx, t.Auth.Sponsor(), im, fee) } // Execute after knowing a transaction can pay a fee. Attempt @@ -323,13 +286,13 @@ func (t *Transaction) PreExecute( func (t *Transaction) Execute( ctx context.Context, feeManager *internalfees.Manager, - s StateManager, + sm StateManager, r Rules, ts *tstate.TStateView, timestamp int64, ) (*Result, error) { // Always charge fee first - units, err := t.Units(s, r) + units, err := t.Units(sm, r) if err != nil { // Should never happen return nil, err @@ -339,7 +302,7 @@ func (t *Transaction) Execute( // Should never happen return nil, err } - if err := s.Deduct(ctx, t.Auth.Sponsor(), ts, fee); err != nil { + if err := sm.Deduct(ctx, t.Auth.Sponsor(), ts, fee); err != nil { // This should never fail for low balance (as we check [CanDeductFee] // immediately before). return nil, err @@ -385,74 +348,88 @@ func (t *Transaction) Execute( }, nil } +// Sponsor is the [codec.Address] that pays fees for this transaction. +func (t *Transaction) Sponsor() codec.Address { return t.Auth.Sponsor() } + func (t *Transaction) Marshal(p *codec.Packer) error { if len(t.bytes) > 0 { p.PackFixedBytes(t.bytes) return p.Err() } - return t.marshal(p, true) + return t.marshal(p) } -func (t *Transaction) marshal(p *codec.Packer, marshalSignature bool) error { - t.Base.Marshal(p) - if err := t.Actions.marshalInto(p); err != nil { +func (t *Transaction) marshal(p *codec.Packer) error { + if err := t.TransactionData.marshal(p); err != nil { return err } - if marshalSignature { - authID := t.Auth.GetTypeID() - p.PackByte(authID) - t.Auth.Marshal(p) - } + authID := t.Auth.GetTypeID() + p.PackByte(authID) + t.Auth.Marshal(p) + return p.Err() } -type Actions []Action - -func (a Actions) Size() (int, error) { - var size int - for _, action := range a { - actionSize, err := GetSize(action) - if err != nil { - return 0, err - } - size += consts.ByteLen + actionSize +// VerifyAuth verifies that the transaction was signed correctly. +func (t *Transaction) VerifyAuth(ctx context.Context) error { + msg, err := t.UnsignedBytes() + if err != nil { + // Should never occur because populated during unmarshal + return err } - return size, nil + return t.Auth.Verify(ctx, msg) } -func (a Actions) marshalInto(p *codec.Packer) error { - p.PackByte(uint8(len(a))) - for _, action := range a { - p.PackByte(action.GetTypeID()) - err := marshalInto(action, p) - if err != nil { - return err - } +func UnmarshalTxData( + p *codec.Packer, + actionRegistry *codec.TypeParser[Action], +) (*TransactionData, error) { + start := p.Offset() + base, err := UnmarshalBase(p) + if err != nil { + return nil, fmt.Errorf("%w: could not unmarshal base", err) } - return nil + actions, err := UnmarshalActions(p, actionRegistry) + if err != nil { + return nil, fmt.Errorf("%w: could not unmarshal actions", err) + } + + var tx TransactionData + tx.Base = base + tx.Actions = actions + if err := p.Err(); err != nil { + return nil, p.Err() + } + codecBytes := p.Bytes() + tx.unsignedBytes = codecBytes[start:p.Offset()] // ensure errors handled before grabbing memory + return &tx, nil } -func MarshalTxs(txs []*Transaction) ([]byte, error) { - if len(txs) == 0 { - return nil, ErrNoTxs +func UnmarshalActions( + p *codec.Packer, + actionRegistry *codec.TypeParser[Action], +) (Actions, error) { + actionCount := p.UnpackByte() + if actionCount == 0 { + return nil, fmt.Errorf("%w: no actions", ErrInvalidObject) } - size := consts.IntLen + codec.CummSize(txs) - p := codec.NewWriter(size, consts.NetworkSizeLimit) - p.PackInt(uint32(len(txs))) - for _, tx := range txs { - if err := tx.Marshal(p); err != nil { - return nil, err + actions := Actions{} + for i := uint8(0); i < actionCount; i++ { + action, err := actionRegistry.Unmarshal(p) + if err != nil { + return nil, fmt.Errorf("%w: could not unmarshal action", err) } + actions = append(actions, action) } - return p.Bytes(), p.Err() + return actions, nil } func UnmarshalTxs( raw []byte, initialCapacity int, - actionRegistry ActionRegistry, - authRegistry AuthRegistry, + actionRegistry *codec.TypeParser[Action], + authRegistry *codec.TypeParser[Auth], ) (map[uint8]int, []*Transaction, error) { p := codec.NewReader(raw, consts.NetworkSizeLimit) txCount := p.UnpackInt(true) @@ -479,15 +456,10 @@ func UnmarshalTx( authRegistry *codec.TypeParser[Auth], ) (*Transaction, error) { start := p.Offset() - base, err := UnmarshalBase(p) + unsignedTransaction, err := UnmarshalTxData(p, actionRegistry) if err != nil { - return nil, fmt.Errorf("%w: could not unmarshal base", err) - } - actions, err := UnmarshalActions(p, actionRegistry) - if err != nil { - return nil, fmt.Errorf("%w: could not unmarshal actions", err) + return nil, err } - digest := p.Offset() auth, err := authRegistry.Unmarshal(p) if err != nil { return nil, fmt.Errorf("%w: could not unmarshal auth", err) @@ -502,35 +474,100 @@ func UnmarshalTx( } var tx Transaction - tx.Base = base - tx.Actions = actions + tx.TransactionData = *unsignedTransaction tx.Auth = auth if err := p.Err(); err != nil { return nil, p.Err() } codecBytes := p.Bytes() - tx.unsignedBytes = codecBytes[start:digest] tx.bytes = codecBytes[start:p.Offset()] // ensure errors handled before grabbing memory tx.size = len(tx.bytes) tx.id = utils.ToID(tx.bytes) return &tx, nil } -func UnmarshalActions( - p *codec.Packer, - actionRegistry *codec.TypeParser[Action], -) (Actions, error) { - actionCount := p.UnpackByte() - if actionCount == 0 { - return nil, fmt.Errorf("%w: no actions", ErrInvalidObject) +func MarshalTxs(txs []*Transaction) ([]byte, error) { + if len(txs) == 0 { + return nil, ErrNoTxs } - actions := Actions{} - for i := uint8(0); i < actionCount; i++ { - action, err := actionRegistry.Unmarshal(p) + size := consts.IntLen + codec.CummSize(txs) + p := codec.NewWriter(size, consts.NetworkSizeLimit) + p.PackInt(uint32(len(txs))) + for _, tx := range txs { + if err := tx.Marshal(p); err != nil { + return nil, err + } + } + return p.Bytes(), p.Err() +} + +// EstimateUnits provides a pessimistic estimate (some key accesses may be duplicates) of the cost +// to execute a transaction. +// +// This is typically used during transaction construction. +func EstimateUnits(r Rules, actions Actions, authFactory AuthFactory) (fees.Dimensions, error) { + var ( + bandwidth = uint64(BaseSize) + stateKeysMaxChunks = []uint16{} // TODO: preallocate + computeOp = math.NewUint64Operator(r.GetBaseComputeUnits()) + readsOp = math.NewUint64Operator(0) + allocatesOp = math.NewUint64Operator(0) + writesOp = math.NewUint64Operator(0) + ) + + // Calculate over action/auth + bandwidth += consts.Uint8Len + for _, action := range actions { + actionSize, err := GetSize(action) if err != nil { - return nil, fmt.Errorf("%w: could not unmarshal action", err) + return fees.Dimensions{}, err } - actions = append(actions, action) + + actor := authFactory.Address() + stateKeys := action.StateKeys(actor) + actionStateKeysMaxChunks, ok := stateKeys.ChunkSizes() + if !ok { + return fees.Dimensions{}, ErrInvalidKeyValue + } + bandwidth += consts.ByteLen + uint64(actionSize) + stateKeysMaxChunks = append(stateKeysMaxChunks, actionStateKeysMaxChunks...) + computeOp.Add(action.ComputeUnits(r)) } - return actions, nil + authBandwidth, authCompute := authFactory.MaxUnits() + bandwidth += consts.ByteLen + authBandwidth + sponsorStateKeyMaxChunks := r.GetSponsorStateKeysMaxChunks() + stateKeysMaxChunks = append(stateKeysMaxChunks, sponsorStateKeyMaxChunks...) + computeOp.Add(authCompute) + + // Estimate compute costs + compute, err := computeOp.Value() + if err != nil { + return fees.Dimensions{}, err + } + + // Estimate storage costs + for _, maxChunks := range stateKeysMaxChunks { + // Compute key costs + readsOp.Add(r.GetStorageKeyReadUnits()) + allocatesOp.Add(r.GetStorageKeyAllocateUnits()) + writesOp.Add(r.GetStorageKeyWriteUnits()) + + // Compute value costs + readsOp.MulAdd(uint64(maxChunks), r.GetStorageValueReadUnits()) + allocatesOp.MulAdd(uint64(maxChunks), r.GetStorageValueAllocateUnits()) + writesOp.MulAdd(uint64(maxChunks), r.GetStorageValueWriteUnits()) + } + reads, err := readsOp.Value() + if err != nil { + return fees.Dimensions{}, err + } + allocates, err := allocatesOp.Value() + if err != nil { + return fees.Dimensions{}, err + } + writes, err := writesOp.Value() + if err != nil { + return fees.Dimensions{}, err + } + return fees.Dimensions{bandwidth, compute, reads, allocates, writes}, nil } diff --git a/chain/transaction_test.go b/chain/transaction_test.go index 66bebd9e4e..9c12ec8cc5 100644 --- a/chain/transaction_test.go +++ b/chain/transaction_test.go @@ -77,7 +77,7 @@ func unmarshalAction2(p *codec.Packer) (chain.Action, error) { func TestMarshalUnmarshal(t *testing.T) { require := require.New(t) - tx := chain.Transaction{ + tx := chain.TransactionData{ Base: &chain.Base{ Timestamp: 1724315246000, ChainID: [32]byte{1, 2, 3, 4, 5, 6, 7}, @@ -105,17 +105,17 @@ func TestMarshalUnmarshal(t *testing.T) { require.NoError(err) factory := auth.NewED25519Factory(priv) - actionRegistry := codec.NewTypeParser[chain.Action]() - authRegistry := codec.NewTypeParser[chain.Auth]() + actionCodec := codec.NewTypeParser[chain.Action]() + authCodec := codec.NewTypeParser[chain.Auth]() - err = authRegistry.Register(&auth.ED25519{}, auth.UnmarshalED25519) + err = authCodec.Register(&auth.ED25519{}, auth.UnmarshalED25519) require.NoError(err) - err = actionRegistry.Register(&mockTransferAction{}, unmarshalTransfer) + err = actionCodec.Register(&mockTransferAction{}, unmarshalTransfer) require.NoError(err) - err = actionRegistry.Register(&action2{}, unmarshalAction2) + err = actionCodec.Register(&action2{}, unmarshalAction2) require.NoError(err) - txBeforeSign := chain.Transaction{ + txBeforeSign := chain.TransactionData{ Base: &chain.Base{ Timestamp: 1724315246000, ChainID: [32]byte{1, 2, 3, 4, 5, 6, 7}, @@ -138,9 +138,11 @@ func TestMarshalUnmarshal(t *testing.T) { }, }, } + // call UnsignedBytes so that the "unsignedBytes" field would get populated. + _, err = txBeforeSign.UnsignedBytes() + require.NoError(err) - require.Nil(tx.Auth) - signedTx, err := tx.Sign(factory, actionRegistry, authRegistry) + signedTx, err := tx.Sign(factory, actionCodec, authCodec) require.NoError(err) require.Equal(txBeforeSign, tx) require.NotNil(signedTx.Auth) diff --git a/cli/spam.go b/cli/spam.go index 00147651e4..7391394b69 100644 --- a/cli/spam.go +++ b/cli/spam.go @@ -1,630 +1,106 @@ // Copyright (C) 2024, Ava Labs, Inc. All rights reserved. // See the file LICENSE for licensing terms. -//nolint:gosec package cli import ( "context" - "encoding/binary" - "fmt" - "math/rand" - "os" - "os/signal" - "runtime" - "strings" - "sync" - "sync/atomic" - "syscall" - "time" - "github.com/ava-labs/avalanchego/utils/set" - "golang.org/x/sync/errgroup" - - "github.com/ava-labs/hypersdk/api/jsonrpc" - "github.com/ava-labs/hypersdk/api/ws" - "github.com/ava-labs/hypersdk/chain" "github.com/ava-labs/hypersdk/cli/prompt" - "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/consts" - "github.com/ava-labs/hypersdk/fees" - "github.com/ava-labs/hypersdk/pubsub" - "github.com/ava-labs/hypersdk/utils" -) - -const ( - pendingTargetMultiplier = 10 - successfulRunsToIncreaseTarget = 10 - failedRunsToDecreaseTarget = 5 - - issuerShutdownTimeout = 60 * time.Second -) - -var ( - maxConcurrency = runtime.NumCPU() - issuerWg sync.WaitGroup - - l sync.Mutex - confirmedTxs uint64 - totalTxs uint64 - - inflight atomic.Int64 - sent atomic.Int64 + "github.com/ava-labs/hypersdk/throughput" ) -type SpamHelper interface { - // CreateAccount generates a new account and returns the [PrivateKey]. - // - // The spammer tracks all created accounts and orchestrates the return of funds - // sent to any created accounts on shutdown. If the spammer exits ungracefully, - // any funds sent to created accounts will be lost unless they are persisted by - // the [SpamHelper] implementation. - CreateAccount() (*PrivateKey, error) - // GetFactory returns the [chain.AuthFactory] for a given private key. - // - // A [chain.AuthFactory] signs transactions and provides a unit estimate - // for using a given private key (needed to estimate fees for a transaction). - GetFactory(pk *PrivateKey) (chain.AuthFactory, error) - - // CreateClient instructs the [SpamHelper] to create and persist a VM-specific - // JSONRPC client. - // - // This client is used to retrieve the [chain.Parser] and the balance - // of arbitrary addresses. - // - // TODO: consider making these functions part of the required JSONRPC - // interface for the HyperSDK. - CreateClient(uri string) error - GetParser(ctx context.Context) (chain.Parser, error) - LookupBalance(choice int, address codec.Address) (uint64, error) - - // GetTransfer returns a list of actions that sends [amount] to a given [address]. - // - // Memo is used to ensure that each transaction is unique (even if between the same - // sender and receiver for the same amount). - GetTransfer(address codec.Address, amount uint64, memo []byte) []chain.Action -} - -func (h *Handler) Spam(sh SpamHelper) error { - ctx := context.Background() - +// BuildSpammer prompts the user for the spammer parameters. If [defaults], the default values are used once the +// chain and root key are selected. Otherwise, the user is prompted for all parameters. +func (h *Handler) BuildSpammer(sh throughput.SpamHelper, defaults bool) (*throughput.Spammer, error) { // Select chain chains, err := h.GetChains() if err != nil { - return err + return nil, err } _, uris, err := prompt.SelectChain("select chainID", chains) if err != nil { - return err - } - cli := jsonrpc.NewJSONRPCClient(uris[0]) - if err != nil { - return err + return nil, err } // Select root key keys, err := h.GetKeys() if err != nil { - return err + return nil, err } - balances := make([]uint64, len(keys)) if err := sh.CreateClient(uris[0]); err != nil { - return err - } - for i := 0; i < len(keys); i++ { - balance, err := sh.LookupBalance(i, keys[i].Address) - if err != nil { - return err - } - balances[i] = balance + return nil, err } + keyIndex, err := prompt.Choice("select root key", len(keys)) if err != nil { - return err + return nil, err } key := keys[keyIndex] - balance := balances[keyIndex] - factory, err := sh.GetFactory(key) - if err != nil { - return err - } - // No longer using db, so we close if err := h.CloseDatabase(); err != nil { - return err + return nil, err } - // Compute max units - parser, err := sh.GetParser(ctx) - if err != nil { - return err + if defaults { + sc := throughput.NewDefaultConfig(uris, key) + return throughput.NewSpammer(sc, sh) } - actions := sh.GetTransfer(keys[0].Address, 0, uniqueBytes()) - maxUnits, err := chain.EstimateUnits(parser.Rules(time.Now().UnixMilli()), actions, factory) - if err != nil { - return err - } - // Collect parameters numAccounts, err := prompt.Int("number of accounts", consts.MaxInt) if err != nil { - return err + return nil, err } if numAccounts < 2 { - return ErrInsufficientAccounts + return nil, ErrInsufficientAccounts } sZipf, err := prompt.Float("s (Zipf distribution = [(v+k)^(-s)], Default = 1.01)", consts.MaxFloat64) if err != nil { - return err + return nil, err } vZipf, err := prompt.Float("v (Zipf distribution = [(v+k)^(-s)], Default = 2.7)", consts.MaxFloat64) if err != nil { - return err + return nil, err } + txsPerSecond, err := prompt.Int("txs to try and issue per second", consts.MaxInt) if err != nil { - return err + return nil, err } minTxsPerSecond, err := prompt.Int("minimum txs to issue per second", consts.MaxInt) if err != nil { - return err + return nil, err } txsPerSecondStep, err := prompt.Int("txs to increase per second", consts.MaxInt) if err != nil { - return err + return nil, err } numClients, err := prompt.Int("number of clients per node", consts.MaxInt) if err != nil { - return err - } - - // Log Zipf participants - zipfSeed := rand.New(rand.NewSource(0)) - zz := rand.NewZipf(zipfSeed, sZipf, vZipf, uint64(numAccounts)-1) - trials := txsPerSecond * 60 * 2 // sender/receiver - unique := set.NewSet[uint64](trials) - for i := 0; i < trials; i++ { - unique.Add(zz.Uint64()) - } - utils.Outf("{{blue}}unique participants expected every 60s:{{/}} %d\n", unique.Len()) - - // Distribute funds - unitPrices, err := cli.UnitPrices(ctx, false) - if err != nil { - return err - } - feePerTx, err := fees.MulSum(unitPrices, maxUnits) - if err != nil { - return err - } - withholding := feePerTx * uint64(numAccounts) - if balance < withholding { - return fmt.Errorf("insufficient funds (have=%d need=%d)", balance, withholding) - } - distAmount := (balance - withholding) / uint64(numAccounts) - utils.Outf( - "{{yellow}}distributing funds to each account:{{/}} %s %s\n", - utils.FormatBalance(distAmount), - h.c.Symbol(), - ) - accounts := make([]*PrivateKey, numAccounts) - webSocketClient, err := ws.NewWebSocketClient(uris[0], ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) // we write the max read - if err != nil { - return err - } - funds := map[codec.Address]uint64{} - factories := make([]chain.AuthFactory, numAccounts) - var fundsL sync.Mutex - p := &pacer{ws: webSocketClient} - go p.Run(ctx, minTxsPerSecond) - for i := 0; i < numAccounts; i++ { - // Create account - pk, err := sh.CreateAccount() - if err != nil { - return err - } - accounts[i] = pk - f, err := sh.GetFactory(pk) - if err != nil { - return err - } - factories[i] = f - - // Send funds - actions := sh.GetTransfer(pk.Address, distAmount, uniqueBytes()) - _, tx, err := cli.GenerateTransactionManual(parser, actions, factory, feePerTx) - if err != nil { - return err - } - if err := p.Add(tx); err != nil { - return fmt.Errorf("%w: failed to register tx", err) - } - funds[pk.Address] = distAmount - - // Log progress - if i%250 == 0 && i > 0 { - utils.Outf("{{yellow}}issued transfer to %d accounts{{/}}\n", i) - } - } - if err := p.Wait(); err != nil { - return err - } - utils.Outf("{{yellow}}distributed funds to %d accounts{{/}}\n", numAccounts) - - // Kickoff txs - issuers := []*issuer{} - for i := 0; i < len(uris); i++ { - for j := 0; j < numClients; j++ { - cli := jsonrpc.NewJSONRPCClient(uris[i]) - webSocketClient, err := ws.NewWebSocketClient(uris[i], ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) // we write the max read - if err != nil { - return err - } - issuer := &issuer{i: len(issuers), cli: cli, ws: webSocketClient, parser: parser, uri: uris[i]} - issuers = append(issuers, issuer) - } - } - signals := make(chan os.Signal, 2) - signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) - - // Start issuers - unitPrices, err = issuers[0].cli.UnitPrices(ctx, false) - if err != nil { - return err + return nil, err } - cctx, cancel := context.WithCancel(ctx) - defer cancel() - for _, issuer := range issuers { - issuer.Start(cctx) - } - - // Log stats - t := time.NewTicker(1 * time.Second) // ensure no duplicates created - defer t.Stop() - var psent int64 - go func() { - for { - select { - case <-t.C: - current := sent.Load() - l.Lock() - if totalTxs > 0 { - unitPrices, err = issuers[0].cli.UnitPrices(ctx, false) - if err != nil { - continue - } - utils.Outf( - "{{yellow}}txs seen:{{/}} %d {{yellow}}success rate:{{/}} %.2f%% {{yellow}}inflight:{{/}} %d {{yellow}}issued/s:{{/}} %d {{yellow}}unit prices:{{/}} [%s]\n", //nolint:lll - totalTxs, - float64(confirmedTxs)/float64(totalTxs)*100, - inflight.Load(), - current-psent, - unitPrices, - ) - } - l.Unlock() - psent = current - case <-cctx.Done(): - return - } - } - }() - // Broadcast txs - var ( - // Do not call this function concurrently (math.Rand is not safe for concurrent use) - z = rand.NewZipf(zipfSeed, sZipf, vZipf, uint64(numAccounts)-1) - - it = time.NewTimer(0) - currentTarget = min(txsPerSecond, minTxsPerSecond) - consecutiveUnderBacklog int - consecutiveAboveBacklog int - - stop bool + sc := throughput.NewConfig( + uris, + key, + sZipf, + vZipf, + txsPerSecond, + minTxsPerSecond, + txsPerSecondStep, + numClients, + numAccounts, ) - utils.Outf("{{cyan}}initial target tps:{{/}} %d\n", currentTarget) - for !stop { - select { - case <-it.C: - start := time.Now() - - // Check to see if we should wait for pending txs - if int64(currentTarget)+inflight.Load() > int64(currentTarget*pendingTargetMultiplier) { - consecutiveUnderBacklog = 0 - consecutiveAboveBacklog++ - if consecutiveAboveBacklog >= failedRunsToDecreaseTarget { - if currentTarget > txsPerSecondStep { - currentTarget -= txsPerSecondStep - utils.Outf("{{cyan}}skipping issuance because large backlog detected, decreasing target tps:{{/}} %d\n", currentTarget) - } else { - utils.Outf("{{cyan}}skipping issuance because large backlog detected, cannot decrease target{{/}}\n") - } - consecutiveAboveBacklog = 0 - } - it.Reset(1 * time.Second) - break - } - - // Issue txs - g := &errgroup.Group{} - g.SetLimit(maxConcurrency) - for i := 0; i < currentTarget; i++ { - senderIndex, recipientIndex := z.Uint64(), z.Uint64() - sender := accounts[senderIndex] - if recipientIndex == senderIndex { - if recipientIndex == uint64(numAccounts-1) { - recipientIndex-- - } else { - recipientIndex++ - } - } - recipient := accounts[recipientIndex].Address - issuer := getRandomIssuer(issuers) - g.Go(func() error { - factory := factories[senderIndex] - fundsL.Lock() - balance := funds[sender.Address] - if feePerTx > balance { - fundsL.Unlock() - utils.Outf("{{orange}}tx has insufficient funds:{{/}} %s\n", sender.Address) - return fmt.Errorf("%s has insufficient funds", sender.Address) - } - funds[sender.Address] = balance - feePerTx - fundsL.Unlock() - // Send transaction - actions := sh.GetTransfer(recipient, 1, uniqueBytes()) - return issuer.Send(cctx, actions, factory, feePerTx) - }) - } - - // Wait for txs to finish - if err := g.Wait(); err != nil { - // We don't return here because we want to return funds - utils.Outf("{{orange}}broadcast loop error:{{/}} %v\n", err) - stop = true - break - } - - // Determine how long to sleep - dur := time.Since(start) - sleep := max(float64(consts.MillisecondsPerSecond-dur.Milliseconds()), 0) - it.Reset(time.Duration(sleep) * time.Millisecond) - - // Check to see if we should increase target - consecutiveAboveBacklog = 0 - consecutiveUnderBacklog++ - if consecutiveUnderBacklog >= successfulRunsToIncreaseTarget && currentTarget < txsPerSecond { - currentTarget = min(currentTarget+txsPerSecondStep, txsPerSecond) - utils.Outf("{{cyan}}increasing target tps:{{/}} %d\n", currentTarget) - consecutiveUnderBacklog = 0 - } - case <-cctx.Done(): - stop = true - utils.Outf("{{yellow}}context canceled{{/}}\n") - case <-signals: - stop = true - utils.Outf("{{yellow}}exiting broadcast loop{{/}}\n") - cancel() - } - } - - // Wait for all issuers to finish - utils.Outf("{{yellow}}waiting for issuers to return{{/}}\n") - issuerWg.Wait() - - // Return funds - utils.Outf("{{yellow}}returning funds to %s{{/}}\n", key.Address) - unitPrices, err = cli.UnitPrices(ctx, false) - if err != nil { - return err - } - feePerTx, err = fees.MulSum(unitPrices, maxUnits) - if err != nil { - return err - } - var returnedBalance uint64 - p = &pacer{ws: webSocketClient} - go p.Run(ctx, minTxsPerSecond) - for i := 0; i < numAccounts; i++ { - // Determine if we should return funds - balance := funds[accounts[i].Address] - if feePerTx > balance { - continue - } - - // Send funds - returnAmt := balance - feePerTx - actions := sh.GetTransfer(key.Address, returnAmt, uniqueBytes()) - _, tx, err := cli.GenerateTransactionManual(parser, actions, factories[i], feePerTx) - if err != nil { - return err - } - if err := p.Add(tx); err != nil { - return err - } - returnedBalance += returnAmt - - if i%250 == 0 && i > 0 { - utils.Outf("{{yellow}}checked %d accounts for fund return{{/}}\n", i) - } - } - if err := p.Wait(); err != nil { - utils.Outf("{{orange}}failed to return funds:{{/}} %v\n", err) - return err - } - utils.Outf( - "{{yellow}}returned funds:{{/}} %s %s\n", - utils.FormatBalance(returnedBalance), - h.c.Symbol(), - ) - return nil + return throughput.NewSpammer(sc, sh) } -type pacer struct { - ws *ws.WebSocketClient - - inflight chan struct{} - done chan error -} - -func (p *pacer) Run(ctx context.Context, max int) { - p.inflight = make(chan struct{}, max) - p.done = make(chan error) - - for range p.inflight { - _, wsErr, result, err := p.ws.ListenTx(ctx) - if err != nil { - p.done <- err - return - } - if wsErr != nil { - p.done <- wsErr - return - } - if !result.Success { - // Should never happen - p.done <- fmt.Errorf("%w: %s", ErrTxFailed, result.Error) - return - } - } - p.done <- nil -} - -func (p *pacer) Add(tx *chain.Transaction) error { - if err := p.ws.RegisterTx(tx); err != nil { - return err - } - select { - case p.inflight <- struct{}{}: - return nil - case err := <-p.done: - return err - } -} - -func (p *pacer) Wait() error { - close(p.inflight) - return <-p.done -} - -type issuer struct { - i int - uri string - parser chain.Parser - - // TODO: clean up potential race conditions here. - l sync.Mutex - cli *jsonrpc.JSONRPCClient - ws *ws.WebSocketClient - outstandingTxs int - abandoned error -} - -func (i *issuer) Start(ctx context.Context) { - issuerWg.Add(1) - go func() { - for { - _, wsErr, result, err := i.ws.ListenTx(context.TODO()) - if err != nil { - return - } - i.l.Lock() - i.outstandingTxs-- - i.l.Unlock() - inflight.Add(-1) - l.Lock() - if result != nil { - if result.Success { - confirmedTxs++ - } else { - utils.Outf("{{orange}}on-chain tx failure:{{/}} %s %t\n", string(result.Error), result.Success) - } - } else { - // We can't error match here because we receive it over the wire. - if !strings.Contains(wsErr.Error(), ws.ErrExpired.Error()) { - utils.Outf("{{orange}}pre-execute tx failure:{{/}} %v\n", wsErr) - } - } - totalTxs++ - l.Unlock() - } - }() - go func() { - defer func() { - _ = i.ws.Close() - issuerWg.Done() - }() - - <-ctx.Done() - start := time.Now() - for time.Since(start) < issuerShutdownTimeout { - if i.ws.Closed() { - return - } - i.l.Lock() - outstanding := i.outstandingTxs - i.l.Unlock() - if outstanding == 0 { - return - } - utils.Outf("{{orange}}waiting for issuer %d to finish:{{/}} %d\n", i.i, outstanding) - time.Sleep(time.Second) - } - utils.Outf("{{orange}}issuer %d shutdown timeout{{/}}\n", i.i) - }() -} - -func (i *issuer) Send(ctx context.Context, actions []chain.Action, factory chain.AuthFactory, feePerTx uint64) error { - // Construct transaction - _, tx, err := i.cli.GenerateTransactionManual(i.parser, actions, factory, feePerTx) +func (h *Handler) Spam(ctx context.Context, sh throughput.SpamHelper, defaults bool) error { + spammer, err := h.BuildSpammer(sh, defaults) if err != nil { - utils.Outf("{{orange}}failed to generate tx:{{/}} %v\n", err) - return fmt.Errorf("failed to generate tx: %w", err) - } - - // Increase outstanding txs for issuer - i.l.Lock() - i.outstandingTxs++ - i.l.Unlock() - inflight.Add(1) - - // Register transaction and recover upon failure - if err := i.ws.RegisterTx(tx); err != nil { - i.l.Lock() - if i.ws.Closed() { - if i.abandoned != nil { - i.l.Unlock() - return i.abandoned - } - - // Attempt to recreate issuer - utils.Outf("{{orange}}re-creating issuer:{{/}} %d {{orange}}uri:{{/}} %s\n", i.i, i.uri) - ws, err := ws.NewWebSocketClient(i.uri, ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) // we write the max read - if err != nil { - i.abandoned = err - utils.Outf("{{orange}}could not re-create closed issuer:{{/}} %v\n", err) - i.l.Unlock() - return err - } - i.ws = ws - i.l.Unlock() - - i.Start(ctx) - utils.Outf("{{green}}re-created closed issuer:{{/}} %d\n", i.i) - } - - // If issuance fails during retry, we should fail - return i.ws.RegisterTx(tx) + return err } - return nil -} - -func getRandomIssuer(issuers []*issuer) *issuer { - index := rand.Int() % len(issuers) - return issuers[index] -} -func uniqueBytes() []byte { - return binary.BigEndian.AppendUint64(nil, uint64(sent.Add(1))) + return spammer.Spam(ctx, sh, false, h.c.Symbol()) } diff --git a/cli/storage.go b/cli/storage.go index dba44bc60d..546b6c6d94 100644 --- a/cli/storage.go +++ b/cli/storage.go @@ -10,6 +10,7 @@ import ( "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/hypersdk/auth" "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/utils" ) @@ -67,7 +68,7 @@ func (h *Handler) GetDefaultChain(log bool) (ids.ID, []string, error) { return chainID, uris, nil } -func (h *Handler) StoreKey(priv *PrivateKey) error { +func (h *Handler) StoreKey(priv *auth.PrivateKey) error { k := make([]byte, 1+codec.AddressLen) k[0] = keyPrefix copy(k[1:], priv.Address[:]) @@ -96,20 +97,15 @@ func (h *Handler) GetKey(addr codec.Address) ([]byte, error) { return v, nil } -type PrivateKey struct { - Address codec.Address - Bytes []byte -} - -func (h *Handler) GetKeys() ([]*PrivateKey, error) { +func (h *Handler) GetKeys() ([]*auth.PrivateKey, error) { iter := h.db.NewIteratorWithPrefix([]byte{keyPrefix}) defer iter.Release() - privateKeys := []*PrivateKey{} + privateKeys := []*auth.PrivateKey{} for iter.Next() { // It is safe to use these bytes directly because the database copies the // iterator value for us. - privateKeys = append(privateKeys, &PrivateKey{ + privateKeys = append(privateKeys, &auth.PrivateKey{ Address: codec.Address(iter.Key()[1:]), Bytes: iter.Value(), }) diff --git a/docs/tutorials/morpheusvm/options.md b/docs/tutorials/morpheusvm/options.md index c6c30d3002..3677e9efb5 100644 --- a/docs/tutorials/morpheusvm/options.md +++ b/docs/tutorials/morpheusvm/options.md @@ -261,16 +261,16 @@ func (p *Parser) Rules(_ int64) chain.Rules { return p.genesis.Rules } -func (p *Parser) ActionRegistry() chain.ActionRegistry { - return p.registry.ActionRegistry() +func (p *Parser) ActionCodec() *codec.TypeParser[chain.Action] { + return p.actionCodec } -func (p *Parser) AuthRegistry() chain.AuthRegistry { - return p.registry.AuthRegistry() +func (p *Parser) AuthCodec() *codec.TypeParser[chain.Auth] { + return p.authCodec } -func (p *Parser) OutputRegistry() chain.OutputRegistry { - return p.registry.OutputRegistry() +func (p *Parser) OutputCodec() *codec.TypeParser[codec.Typed] { + return p.outputCodec } func (*Parser) StateManager() chain.StateManager { diff --git a/examples/morpheusvm/actions/transfer.go b/examples/morpheusvm/actions/transfer.go index 7ccd262a80..57cd253201 100644 --- a/examples/morpheusvm/actions/transfer.go +++ b/examples/morpheusvm/actions/transfer.go @@ -11,7 +11,6 @@ import ( "github.com/ava-labs/hypersdk/chain" "github.com/ava-labs/hypersdk/codec" - "github.com/ava-labs/hypersdk/consts" "github.com/ava-labs/hypersdk/examples/morpheusvm/storage" "github.com/ava-labs/hypersdk/state" @@ -89,27 +88,6 @@ func (*Transfer) ValidRange(chain.Rules) (int64, int64) { return -1, -1 } -// Implementing chain.Marshaler is optional but can be used to optimize performance when hitting TPS limits -var _ chain.Marshaler = (*Transfer)(nil) - -func (t *Transfer) Size() int { - return codec.AddressLen + consts.Uint64Len + codec.BytesLen(t.Memo) -} - -func (t *Transfer) Marshal(p *codec.Packer) { - p.PackAddress(t.To) - p.PackLong(t.Value) - p.PackBytes(t.Memo) -} - -func UnmarshalTransfer(p *codec.Packer) (chain.Action, error) { - var transfer Transfer - p.UnpackAddress(&transfer.To) - transfer.Value = p.UnpackUint64(true) - p.UnpackBytes(MaxMemoSize, false, &transfer.Memo) - return &transfer, p.Err() -} - var _ codec.Typed = (*TransferResult)(nil) type TransferResult struct { diff --git a/examples/morpheusvm/auth/keys.go b/examples/morpheusvm/auth/keys.go new file mode 100644 index 0000000000..5ec5c47f97 --- /dev/null +++ b/examples/morpheusvm/auth/keys.go @@ -0,0 +1,108 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Package auth provides utilities for generating and loading private keys. +// This package is only used for testing and CLI purposes and is not required +// to be implemented by the VM developer. + +package auth + +import ( + "errors" + "fmt" + + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/crypto/bls" + "github.com/ava-labs/hypersdk/crypto/ed25519" + "github.com/ava-labs/hypersdk/crypto/secp256r1" + "github.com/ava-labs/hypersdk/utils" +) + +var ErrInvalidKeyType = errors.New("invalid key type") + +// TODO: make these functions general purpose where the VM provides a set of valid strings, +// functions to generate corresponding new private keys, and the functionality +// to unmarshal private key bytes into the correct type. +func CheckKeyType(k string) error { + switch k { + case auth.ED25519Key, auth.Secp256r1Key, auth.BLSKey: + return nil + default: + return fmt.Errorf("%w: %s", ErrInvalidKeyType, k) + } +} + +func GeneratePrivateKey(k string) (*auth.PrivateKey, error) { + switch k { + case auth.ED25519Key: + p, err := ed25519.GeneratePrivateKey() + if err != nil { + return nil, err + } + return &auth.PrivateKey{ + Address: auth.NewED25519Address(p.PublicKey()), + Bytes: p[:], + }, nil + case auth.Secp256r1Key: + p, err := secp256r1.GeneratePrivateKey() + if err != nil { + return nil, err + } + return &auth.PrivateKey{ + Address: auth.NewSECP256R1Address(p.PublicKey()), + Bytes: p[:], + }, nil + case auth.BLSKey: + p, err := bls.GeneratePrivateKey() + if err != nil { + return nil, err + } + return &auth.PrivateKey{ + Address: auth.NewBLSAddress(bls.PublicFromPrivateKey(p)), + Bytes: bls.PrivateKeyToBytes(p), + }, nil + default: + return nil, ErrInvalidKeyType + } +} + +func LoadPrivateKey(k string, path string) (*auth.PrivateKey, error) { + switch k { + case auth.ED25519Key: + p, err := utils.LoadBytes(path, ed25519.PrivateKeyLen) + if err != nil { + return nil, err + } + pk := ed25519.PrivateKey(p) + return &auth.PrivateKey{ + Address: auth.NewED25519Address(pk.PublicKey()), + Bytes: p, + }, nil + case auth.Secp256r1Key: + p, err := utils.LoadBytes(path, secp256r1.PrivateKeyLen) + if err != nil { + return nil, err + } + pk := secp256r1.PrivateKey(p) + return &auth.PrivateKey{ + Address: auth.NewSECP256R1Address(pk.PublicKey()), + Bytes: p, + }, nil + case auth.BLSKey: + p, err := utils.LoadBytes(path, bls.PrivateKeyLen) + if err != nil { + return nil, err + } + + privKey, err := bls.PrivateKeyFromBytes(p) + if err != nil { + return nil, err + } + return &auth.PrivateKey{ + Address: auth.NewBLSAddress(bls.PublicFromPrivateKey(privKey)), + Bytes: p, + }, nil + default: + return nil, ErrInvalidKeyType + } +} diff --git a/examples/morpheusvm/cmd/morpheus-cli/cmd/handler.go b/examples/morpheusvm/cmd/morpheus-cli/cmd/handler.go index d8f957c26f..f913572fe2 100644 --- a/examples/morpheusvm/cmd/morpheus-cli/cmd/handler.go +++ b/examples/morpheusvm/cmd/morpheus-cli/cmd/handler.go @@ -38,7 +38,7 @@ func (h *Handler) Root() *cli.Handler { } func (h *Handler) DefaultActor() ( - ids.ID, *cli.PrivateKey, chain.AuthFactory, + ids.ID, *auth.PrivateKey, chain.AuthFactory, *jsonrpc.JSONRPCClient, *vm.JSONRPCClient, *ws.WebSocketClient, error, ) { addr, priv, err := h.h.GetDefaultKey(true) @@ -73,7 +73,7 @@ func (h *Handler) DefaultActor() ( return ids.Empty, nil, nil, nil, nil, nil, err } // For [defaultActor], we always send requests to the first returned URI. - return chainID, &cli.PrivateKey{ + return chainID, &auth.PrivateKey{ Address: addr, Bytes: priv, }, factory, jcli, diff --git a/examples/morpheusvm/cmd/morpheus-cli/cmd/key.go b/examples/morpheusvm/cmd/morpheus-cli/cmd/key.go index 000c092b31..edb0e6014c 100644 --- a/examples/morpheusvm/cmd/morpheus-cli/cmd/key.go +++ b/examples/morpheusvm/cmd/morpheus-cli/cmd/key.go @@ -4,108 +4,12 @@ package cmd import ( - "fmt" - "github.com/spf13/cobra" - "github.com/ava-labs/hypersdk/auth" - "github.com/ava-labs/hypersdk/cli" - "github.com/ava-labs/hypersdk/crypto/bls" - "github.com/ava-labs/hypersdk/crypto/ed25519" - "github.com/ava-labs/hypersdk/crypto/secp256r1" + "github.com/ava-labs/hypersdk/examples/morpheusvm/auth" "github.com/ava-labs/hypersdk/utils" ) -const ( - ed25519Key = "ed25519" - secp256r1Key = "secp256r1" - blsKey = "bls" -) - -func checkKeyType(k string) error { - switch k { - case ed25519Key, secp256r1Key, blsKey: - return nil - default: - return fmt.Errorf("%w: %s", ErrInvalidKeyType, k) - } -} - -func generatePrivateKey(k string) (*cli.PrivateKey, error) { - switch k { - case ed25519Key: - p, err := ed25519.GeneratePrivateKey() - if err != nil { - return nil, err - } - return &cli.PrivateKey{ - Address: auth.NewED25519Address(p.PublicKey()), - Bytes: p[:], - }, nil - case secp256r1Key: - p, err := secp256r1.GeneratePrivateKey() - if err != nil { - return nil, err - } - return &cli.PrivateKey{ - Address: auth.NewSECP256R1Address(p.PublicKey()), - Bytes: p[:], - }, nil - case blsKey: - p, err := bls.GeneratePrivateKey() - if err != nil { - return nil, err - } - return &cli.PrivateKey{ - Address: auth.NewBLSAddress(bls.PublicFromPrivateKey(p)), - Bytes: bls.PrivateKeyToBytes(p), - }, nil - default: - return nil, ErrInvalidKeyType - } -} - -func loadPrivateKey(k string, path string) (*cli.PrivateKey, error) { - switch k { - case ed25519Key: - p, err := utils.LoadBytes(path, ed25519.PrivateKeyLen) - if err != nil { - return nil, err - } - pk := ed25519.PrivateKey(p) - return &cli.PrivateKey{ - Address: auth.NewED25519Address(pk.PublicKey()), - Bytes: p, - }, nil - case secp256r1Key: - p, err := utils.LoadBytes(path, secp256r1.PrivateKeyLen) - if err != nil { - return nil, err - } - pk := secp256r1.PrivateKey(p) - return &cli.PrivateKey{ - Address: auth.NewSECP256R1Address(pk.PublicKey()), - Bytes: p, - }, nil - case blsKey: - p, err := utils.LoadBytes(path, bls.PrivateKeyLen) - if err != nil { - return nil, err - } - - privKey, err := bls.PrivateKeyFromBytes(p) - if err != nil { - return nil, err - } - return &cli.PrivateKey{ - Address: auth.NewBLSAddress(bls.PublicFromPrivateKey(privKey)), - Bytes: p, - }, nil - default: - return nil, ErrInvalidKeyType - } -} - var keyCmd = &cobra.Command{ Use: "key", RunE: func(*cobra.Command, []string) error { @@ -119,10 +23,10 @@ var genKeyCmd = &cobra.Command{ if len(args) != 1 { return ErrInvalidArgs } - return checkKeyType(args[0]) + return auth.CheckKeyType(args[0]) }, RunE: func(_ *cobra.Command, args []string) error { - priv, err := generatePrivateKey(args[0]) + priv, err := auth.GeneratePrivateKey(args[0]) if err != nil { return err } @@ -146,10 +50,10 @@ var importKeyCmd = &cobra.Command{ if len(args) != 2 { return ErrInvalidArgs } - return checkKeyType(args[0]) + return auth.CheckKeyType(args[0]) }, RunE: func(_ *cobra.Command, args []string) error { - priv, err := loadPrivateKey(args[0], args[1]) + priv, err := auth.LoadPrivateKey(args[0], args[1]) if err != nil { return err } diff --git a/examples/morpheusvm/cmd/morpheus-cli/cmd/root.go b/examples/morpheusvm/cmd/morpheus-cli/cmd/root.go index 6f58834b86..b3187e39a6 100644 --- a/examples/morpheusvm/cmd/morpheus-cli/cmd/root.go +++ b/examples/morpheusvm/cmd/morpheus-cli/cmd/root.go @@ -30,6 +30,7 @@ var ( minBlockGap int64 hideTxs bool checkAllChains bool + spamDefaults bool prometheusBaseURI string prometheusOpenBrowser bool prometheusFile string @@ -142,6 +143,13 @@ func init() { transferCmd, ) + runSpamCmd.PersistentFlags().BoolVar( + &spamDefaults, + "defaults", + false, + "use default spam parameters", + ) + // spam spamCmd.AddCommand( runSpamCmd, diff --git a/examples/morpheusvm/cmd/morpheus-cli/cmd/spam.go b/examples/morpheusvm/cmd/morpheus-cli/cmd/spam.go index 289c45624b..f159879511 100644 --- a/examples/morpheusvm/cmd/morpheus-cli/cmd/spam.go +++ b/examples/morpheusvm/cmd/morpheus-cli/cmd/spam.go @@ -8,85 +8,10 @@ import ( "github.com/spf13/cobra" - "github.com/ava-labs/hypersdk/api/ws" - "github.com/ava-labs/hypersdk/auth" - "github.com/ava-labs/hypersdk/chain" - "github.com/ava-labs/hypersdk/cli" - "github.com/ava-labs/hypersdk/codec" - "github.com/ava-labs/hypersdk/crypto/bls" - "github.com/ava-labs/hypersdk/crypto/ed25519" - "github.com/ava-labs/hypersdk/crypto/secp256r1" - "github.com/ava-labs/hypersdk/examples/morpheusvm/actions" - "github.com/ava-labs/hypersdk/examples/morpheusvm/consts" - "github.com/ava-labs/hypersdk/examples/morpheusvm/vm" - "github.com/ava-labs/hypersdk/pubsub" - "github.com/ava-labs/hypersdk/utils" + "github.com/ava-labs/hypersdk/examples/morpheusvm/auth" + "github.com/ava-labs/hypersdk/examples/morpheusvm/throughput" ) -type SpamHelper struct { - keyType string - cli *vm.JSONRPCClient - ws *ws.WebSocketClient -} - -func (sh *SpamHelper) CreateAccount() (*cli.PrivateKey, error) { - return generatePrivateKey(sh.keyType) -} - -func (*SpamHelper) GetFactory(pk *cli.PrivateKey) (chain.AuthFactory, error) { - switch pk.Address[0] { - case auth.ED25519ID: - return auth.NewED25519Factory(ed25519.PrivateKey(pk.Bytes)), nil - case auth.SECP256R1ID: - return auth.NewSECP256R1Factory(secp256r1.PrivateKey(pk.Bytes)), nil - case auth.BLSID: - p, err := bls.PrivateKeyFromBytes(pk.Bytes) - if err != nil { - return nil, err - } - return auth.NewBLSFactory(p), nil - default: - return nil, ErrInvalidKeyType - } -} - -func (sh *SpamHelper) CreateClient(uri string) error { - sh.cli = vm.NewJSONRPCClient(uri) - ws, err := ws.NewWebSocketClient(uri, ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) - if err != nil { - return err - } - sh.ws = ws - return nil -} - -func (sh *SpamHelper) GetParser(ctx context.Context) (chain.Parser, error) { - return sh.cli.Parser(ctx) -} - -func (sh *SpamHelper) LookupBalance(choice int, address codec.Address) (uint64, error) { - balance, err := sh.cli.Balance(context.TODO(), address) - if err != nil { - return 0, err - } - utils.Outf( - "%d) {{cyan}}address:{{/}} %s {{cyan}}balance:{{/}} %s %s\n", - choice, - address, - utils.FormatBalance(balance), - consts.Symbol, - ) - return balance, err -} - -func (*SpamHelper) GetTransfer(address codec.Address, amount uint64, memo []byte) []chain.Action { - return []chain.Action{&actions.Transfer{ - To: address, - Value: amount, - Memo: memo, - }} -} - var spamCmd = &cobra.Command{ Use: "spam", RunE: func(*cobra.Command, []string) error { @@ -100,9 +25,10 @@ var runSpamCmd = &cobra.Command{ if len(args) != 1 { return ErrInvalidArgs } - return checkKeyType(args[0]) + return auth.CheckKeyType(args[0]) }, RunE: func(_ *cobra.Command, args []string) error { - return handler.Root().Spam(&SpamHelper{keyType: args[0]}) + ctx := context.Background() + return handler.Root().Spam(ctx, &throughput.SpamHelper{KeyType: args[0]}, spamDefaults) }, } diff --git a/examples/morpheusvm/tests/e2e/e2e_test.go b/examples/morpheusvm/tests/e2e/e2e_test.go index 6d35dbac84..f3bf27a3bb 100644 --- a/examples/morpheusvm/tests/e2e/e2e_test.go +++ b/examples/morpheusvm/tests/e2e/e2e_test.go @@ -11,8 +11,10 @@ import ( "github.com/stretchr/testify/require" "github.com/ava-labs/hypersdk/abi" + "github.com/ava-labs/hypersdk/auth" "github.com/ava-labs/hypersdk/examples/morpheusvm/consts" "github.com/ava-labs/hypersdk/examples/morpheusvm/tests/workload" + "github.com/ava-labs/hypersdk/examples/morpheusvm/throughput" "github.com/ava-labs/hypersdk/examples/morpheusvm/vm" "github.com/ava-labs/hypersdk/tests/fixture" @@ -36,7 +38,7 @@ func init() { var _ = ginkgo.SynchronizedBeforeSuite(func() []byte { require := require.New(ginkgo.GinkgoT()) - gen, workloadFactory, err := workload.New(100 /* minBlockGap: 100ms */) + gen, workloadFactory, spamKey, err := workload.New(100 /* minBlockGap: 100ms */) require.NoError(err) genesisBytes, err := json.Marshal(gen) @@ -53,9 +55,12 @@ var _ = ginkgo.SynchronizedBeforeSuite(func() []byte { // Import HyperSDK e2e test coverage and inject MorpheusVM name // and workload factory to orchestrate the test. - he2e.SetWorkload(consts.Name, workloadFactory, parser, expectedABI) + spamHelper := throughput.SpamHelper{ + KeyType: auth.ED25519Key, + } tc := e2e.NewTestContext() + he2e.SetWorkload(consts.Name, workloadFactory, expectedABI, parser, &spamHelper, spamKey) return fixture.NewTestEnvironment(tc, flagVars, owner, consts.Name, consts.ID, genesisBytes).Marshal() }, func(envBytes []byte) { diff --git a/examples/morpheusvm/tests/integration/integration_test.go b/examples/morpheusvm/tests/integration/integration_test.go index ad65e27433..97b1a28312 100644 --- a/examples/morpheusvm/tests/integration/integration_test.go +++ b/examples/morpheusvm/tests/integration/integration_test.go @@ -25,7 +25,7 @@ func TestIntegration(t *testing.T) { var _ = ginkgo.BeforeSuite(func() { require := require.New(ginkgo.GinkgoT()) - genesis, workloadFactory, err := morpheusWorkload.New(0 /* minBlockGap: 0ms */) + genesis, workloadFactory, _, err := morpheusWorkload.New(0 /* minBlockGap: 0ms */) require.NoError(err) genesisBytes, err := json.Marshal(genesis) diff --git a/examples/morpheusvm/tests/workload/workload.go b/examples/morpheusvm/tests/workload/workload.go index c51f7d4807..a69c365497 100644 --- a/examples/morpheusvm/tests/workload/workload.go +++ b/examples/morpheusvm/tests/workload/workload.go @@ -63,7 +63,7 @@ type workloadFactory struct { addrs []codec.Address } -func New(minBlockGap int64) (*genesis.DefaultGenesis, workload.TxWorkloadFactory, error) { +func New(minBlockGap int64) (*genesis.DefaultGenesis, workload.TxWorkloadFactory, *auth.PrivateKey, error) { customAllocs := make([]*genesis.CustomAllocation, 0, len(ed25519Addrs)) for _, prefundedAddr := range ed25519Addrs { customAllocs = append(customAllocs, &genesis.CustomAllocation{ @@ -71,7 +71,10 @@ func New(minBlockGap int64) (*genesis.DefaultGenesis, workload.TxWorkloadFactory Balance: initialBalance, }) } - + spamKey := &auth.PrivateKey{ + Address: ed25519Addrs[0], + Bytes: ed25519PrivKeys[0][:], + } genesis := genesis.NewDefaultGenesis(customAllocs) // Set WindowTargetUnits to MaxUint64 for all dimensions to iterate full mempool during block building. genesis.Rules.WindowTargetUnits = fees.Dimensions{math.MaxUint64, math.MaxUint64, math.MaxUint64, math.MaxUint64, math.MaxUint64} @@ -83,7 +86,7 @@ func New(minBlockGap int64) (*genesis.DefaultGenesis, workload.TxWorkloadFactory return genesis, &workloadFactory{ factories: ed25519AuthFactories, addrs: ed25519Addrs, - }, nil + }, spamKey, nil } func (f *workloadFactory) NewSizedTxWorkload(uri string, size int) (workload.TxWorkloadIterator, error) { diff --git a/examples/morpheusvm/throughput/helper.go b/examples/morpheusvm/throughput/helper.go new file mode 100644 index 0000000000..33ab2f95a9 --- /dev/null +++ b/examples/morpheusvm/throughput/helper.go @@ -0,0 +1,65 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Package throughput implements the SpamHelper interface. This package is not +// required to be implemented by the VM developer. + +package throughput + +import ( + "context" + + "github.com/ava-labs/hypersdk/api/ws" + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/examples/morpheusvm/actions" + "github.com/ava-labs/hypersdk/examples/morpheusvm/vm" + "github.com/ava-labs/hypersdk/pubsub" + "github.com/ava-labs/hypersdk/throughput" + + mauth "github.com/ava-labs/hypersdk/examples/morpheusvm/auth" +) + +type SpamHelper struct { + KeyType string + cli *vm.JSONRPCClient + ws *ws.WebSocketClient +} + +var _ throughput.SpamHelper = &SpamHelper{} + +func (sh *SpamHelper) CreateAccount() (*auth.PrivateKey, error) { + return mauth.GeneratePrivateKey(sh.KeyType) +} + +func (sh *SpamHelper) CreateClient(uri string) error { + sh.cli = vm.NewJSONRPCClient(uri) + ws, err := ws.NewWebSocketClient(uri, ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) + if err != nil { + return err + } + sh.ws = ws + return nil +} + +func (sh *SpamHelper) GetParser(ctx context.Context) (chain.Parser, error) { + return sh.cli.Parser(ctx) +} + +func (sh *SpamHelper) LookupBalance(address codec.Address) (uint64, error) { + balance, err := sh.cli.Balance(context.TODO(), address) + if err != nil { + return 0, err + } + + return balance, err +} + +func (*SpamHelper) GetTransfer(address codec.Address, amount uint64, memo []byte) []chain.Action { + return []chain.Action{&actions.Transfer{ + To: address, + Value: amount, + Memo: memo, + }} +} diff --git a/examples/morpheusvm/vm/client.go b/examples/morpheusvm/vm/client.go index 3ae8781b82..ea6352554c 100644 --- a/examples/morpheusvm/vm/client.go +++ b/examples/morpheusvm/vm/client.go @@ -111,16 +111,16 @@ func (p *Parser) Rules(_ int64) chain.Rules { return p.genesis.Rules } -func (p *Parser) ActionRegistry() chain.ActionRegistry { +func (p *Parser) ActionCodec() *codec.TypeParser[chain.Action] { return p.registry.ActionRegistry() } -func (p *Parser) AuthRegistry() chain.AuthRegistry { - return p.registry.AuthRegistry() +func (p *Parser) OutputCodec() *codec.TypeParser[codec.Typed] { + return p.registry.OutputRegistry() } -func (p *Parser) OutputRegistry() chain.OutputRegistry { - return p.registry.OutputRegistry() +func (p *Parser) AuthCodec() *codec.TypeParser[chain.Auth] { + return p.registry.AuthRegistry() } func (*Parser) StateManager() chain.StateManager { diff --git a/examples/morpheusvm/vm/vm.go b/examples/morpheusvm/vm/vm.go index ccf1ecd4a3..7aa0c991cb 100644 --- a/examples/morpheusvm/vm/vm.go +++ b/examples/morpheusvm/vm/vm.go @@ -26,8 +26,7 @@ func newRegistry() (chain.Registry, error) { errs.Add( // When registering new actions, ALWAYS make sure to append at the end. // Pass nil as second argument if manual marshalling isn't needed (if in doubt, you probably don't) - actionParser.Register(&actions.Transfer{}, actions.UnmarshalTransfer), - + actionParser.Register(&actions.Transfer{}, nil), // When registering new auth, ALWAYS make sure to append at the end. authParser.Register(&auth.ED25519{}, auth.UnmarshalED25519), authParser.Register(&auth.SECP256R1{}, auth.UnmarshalSECP256R1), diff --git a/examples/vmwithcontracts/actions/call.go b/examples/vmwithcontracts/actions/call.go index 2aa36fd404..60271a94c5 100644 --- a/examples/vmwithcontracts/actions/call.go +++ b/examples/vmwithcontracts/actions/call.go @@ -21,7 +21,10 @@ import ( var _ chain.Action = (*Call)(nil) -const MaxCallDataSize = units.MiB +const ( + MaxCallDataSize = units.MiB + MaxResultSizeLimit = units.MiB +) type StateKeyPermission struct { Key string @@ -68,7 +71,7 @@ func (t *Call) Execute( actor codec.Address, _ ids.ID, ) (codec.Typed, error) { - resutBytes, err := t.r.CallContract(ctx, &runtime.CallInfo{ + callInfo := &runtime.CallInfo{ Contract: t.ContractAddress, Actor: actor, State: &storage.ContractStateManager{Mutable: mu}, @@ -77,11 +80,13 @@ func (t *Call) Execute( Timestamp: uint64(timestamp), Fuel: t.Fuel, Value: t.Value, - }) + } + resultBytes, err := t.r.CallContract(ctx, callInfo) if err != nil { return nil, err } - return &Result{Value: resutBytes}, nil + consumedFuel := t.Fuel - callInfo.RemainingFuel() + return &Result{Value: resultBytes, ConsumedFuel: consumedFuel}, nil } func (t *Call) ComputeUnits(chain.Rules) uint64 { @@ -134,7 +139,8 @@ func (*Call) ValidRange(chain.Rules) (int64, int64) { } type Result struct { - Value []byte `serialize:"true" json:"value"` + Value []byte `serialize:"true" json:"value"` + ConsumedFuel uint64 `serialize:"true" json:"consumedfuel"` } func (*Result) GetTypeID() uint8 { diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/action.go b/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/action.go index 3c0f1ffe0a..da13e4255e 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/action.go +++ b/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/action.go @@ -5,6 +5,8 @@ package cmd import ( "context" + "errors" + "fmt" "os" "github.com/near/borsh-go" @@ -15,9 +17,12 @@ import ( "github.com/ava-labs/hypersdk/cli/prompt" "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/examples/vmwithcontracts/actions" + "github.com/ava-labs/hypersdk/examples/vmwithcontracts/vm" "github.com/ava-labs/hypersdk/utils" ) +var errUnexpectedSimulateActionsOutput = errors.New("returned output from SimulateActions was not actions.Result") + var actionCmd = &cobra.Command{ Use: "action", RunE: func(*cobra.Command, []string) error { @@ -141,18 +146,34 @@ var callCmd = &cobra.Command{ ContractAddress: contractAddress, Value: amount, Function: function, + Fuel: uint64(1000000000), + } + + actionSimulationResults, err := cli.SimulateActions(ctx, chain.Actions{action}, priv.Address) + if err != nil { + return err + } + if len(actionSimulationResults) != 1 { + return fmt.Errorf("unexpected number of returned actions. One action expected, %d returned", len(actionSimulationResults)) } + actionSimulationResult := actionSimulationResults[0] - specifiedStateKeysSet, fuel, err := bcli.Simulate(ctx, *action, priv.Address) + rtx := codec.NewReader(actionSimulationResult.Output, len(actionSimulationResult.Output)) + + simulationResultOutput, err := (*vm.OutputParser).Unmarshal(rtx) if err != nil { return err } + simulationResult, ok := simulationResultOutput.(*actions.Result) + if !ok { + return errUnexpectedSimulateActionsOutput + } - action.SpecifiedStateKeys = make([]actions.StateKeyPermission, 0, len(specifiedStateKeysSet)) - for key, value := range specifiedStateKeysSet { + action.SpecifiedStateKeys = make([]actions.StateKeyPermission, 0, len(actionSimulationResult.StateKeys)) + for key, value := range actionSimulationResult.StateKeys { action.SpecifiedStateKeys = append(action.SpecifiedStateKeys, actions.StateKeyPermission{Key: key, Permission: value}) } - action.Fuel = fuel + action.Fuel = simulationResult.ConsumedFuel // Confirm action cont, err := prompt.Continue() diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/handler.go b/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/handler.go index 142ec89b00..2d31491c15 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/handler.go +++ b/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/handler.go @@ -38,7 +38,7 @@ func (h *Handler) Root() *cli.Handler { } func (h *Handler) DefaultActor() ( - ids.ID, *cli.PrivateKey, chain.AuthFactory, + ids.ID, *auth.PrivateKey, chain.AuthFactory, *jsonrpc.JSONRPCClient, *vm.JSONRPCClient, *ws.WebSocketClient, error, ) { addr, priv, err := h.h.GetDefaultKey(true) @@ -73,7 +73,7 @@ func (h *Handler) DefaultActor() ( return ids.Empty, nil, nil, nil, nil, nil, err } // For [defaultActor], we always send requests to the first returned URI. - return chainID, &cli.PrivateKey{ + return chainID, &auth.PrivateKey{ Address: addr, Bytes: priv, }, factory, jcli, diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/key.go b/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/key.go index 000c092b31..950d0cb166 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/key.go +++ b/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/key.go @@ -9,7 +9,6 @@ import ( "github.com/spf13/cobra" "github.com/ava-labs/hypersdk/auth" - "github.com/ava-labs/hypersdk/cli" "github.com/ava-labs/hypersdk/crypto/bls" "github.com/ava-labs/hypersdk/crypto/ed25519" "github.com/ava-labs/hypersdk/crypto/secp256r1" @@ -31,14 +30,14 @@ func checkKeyType(k string) error { } } -func generatePrivateKey(k string) (*cli.PrivateKey, error) { +func generatePrivateKey(k string) (*auth.PrivateKey, error) { switch k { case ed25519Key: p, err := ed25519.GeneratePrivateKey() if err != nil { return nil, err } - return &cli.PrivateKey{ + return &auth.PrivateKey{ Address: auth.NewED25519Address(p.PublicKey()), Bytes: p[:], }, nil @@ -47,7 +46,7 @@ func generatePrivateKey(k string) (*cli.PrivateKey, error) { if err != nil { return nil, err } - return &cli.PrivateKey{ + return &auth.PrivateKey{ Address: auth.NewSECP256R1Address(p.PublicKey()), Bytes: p[:], }, nil @@ -56,7 +55,7 @@ func generatePrivateKey(k string) (*cli.PrivateKey, error) { if err != nil { return nil, err } - return &cli.PrivateKey{ + return &auth.PrivateKey{ Address: auth.NewBLSAddress(bls.PublicFromPrivateKey(p)), Bytes: bls.PrivateKeyToBytes(p), }, nil @@ -65,7 +64,7 @@ func generatePrivateKey(k string) (*cli.PrivateKey, error) { } } -func loadPrivateKey(k string, path string) (*cli.PrivateKey, error) { +func loadPrivateKey(k string, path string) (*auth.PrivateKey, error) { switch k { case ed25519Key: p, err := utils.LoadBytes(path, ed25519.PrivateKeyLen) @@ -73,7 +72,7 @@ func loadPrivateKey(k string, path string) (*cli.PrivateKey, error) { return nil, err } pk := ed25519.PrivateKey(p) - return &cli.PrivateKey{ + return &auth.PrivateKey{ Address: auth.NewED25519Address(pk.PublicKey()), Bytes: p, }, nil @@ -83,7 +82,7 @@ func loadPrivateKey(k string, path string) (*cli.PrivateKey, error) { return nil, err } pk := secp256r1.PrivateKey(p) - return &cli.PrivateKey{ + return &auth.PrivateKey{ Address: auth.NewSECP256R1Address(pk.PublicKey()), Bytes: p, }, nil @@ -97,7 +96,7 @@ func loadPrivateKey(k string, path string) (*cli.PrivateKey, error) { if err != nil { return nil, err } - return &cli.PrivateKey{ + return &auth.PrivateKey{ Address: auth.NewBLSAddress(bls.PublicFromPrivateKey(privKey)), Bytes: p, }, nil diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/spam.go b/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/spam.go index 4e6e53b8e2..f13783ef36 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/spam.go +++ b/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/spam.go @@ -11,16 +11,10 @@ import ( "github.com/ava-labs/hypersdk/api/ws" "github.com/ava-labs/hypersdk/auth" "github.com/ava-labs/hypersdk/chain" - "github.com/ava-labs/hypersdk/cli" "github.com/ava-labs/hypersdk/codec" - "github.com/ava-labs/hypersdk/crypto/bls" - "github.com/ava-labs/hypersdk/crypto/ed25519" - "github.com/ava-labs/hypersdk/crypto/secp256r1" "github.com/ava-labs/hypersdk/examples/vmwithcontracts/actions" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" "github.com/ava-labs/hypersdk/examples/vmwithcontracts/vm" "github.com/ava-labs/hypersdk/pubsub" - "github.com/ava-labs/hypersdk/utils" ) type SpamHelper struct { @@ -29,27 +23,10 @@ type SpamHelper struct { ws *ws.WebSocketClient } -func (sh *SpamHelper) CreateAccount() (*cli.PrivateKey, error) { +func (sh *SpamHelper) CreateAccount() (*auth.PrivateKey, error) { return generatePrivateKey(sh.keyType) } -func (*SpamHelper) GetFactory(pk *cli.PrivateKey) (chain.AuthFactory, error) { - switch pk.Address[0] { - case auth.ED25519ID: - return auth.NewED25519Factory(ed25519.PrivateKey(pk.Bytes)), nil - case auth.SECP256R1ID: - return auth.NewSECP256R1Factory(secp256r1.PrivateKey(pk.Bytes)), nil - case auth.BLSID: - p, err := bls.PrivateKeyFromBytes(pk.Bytes) - if err != nil { - return nil, err - } - return auth.NewBLSFactory(p), nil - default: - return nil, ErrInvalidKeyType - } -} - func (sh *SpamHelper) CreateClient(uri string) error { sh.cli = vm.NewJSONRPCClient(uri) ws, err := ws.NewWebSocketClient(uri, ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) @@ -64,18 +41,12 @@ func (sh *SpamHelper) GetParser(ctx context.Context) (chain.Parser, error) { return sh.cli.Parser(ctx) } -func (sh *SpamHelper) LookupBalance(choice int, address codec.Address) (uint64, error) { +func (sh *SpamHelper) LookupBalance(address codec.Address) (uint64, error) { balance, err := sh.cli.Balance(context.TODO(), address) if err != nil { return 0, err } - utils.Outf( - "%d) {{cyan}}address:{{/}} %s {{cyan}}balance:{{/}} %s %s\n", - choice, - address, - utils.FormatBalance(balance), - consts.Symbol, - ) + return balance, err } @@ -103,6 +74,7 @@ var runSpamCmd = &cobra.Command{ return checkKeyType(args[0]) }, RunE: func(_ *cobra.Command, args []string) error { - return handler.Root().Spam(&SpamHelper{keyType: args[0]}) + ctx := context.Background() + return handler.Root().Spam(ctx, &SpamHelper{keyType: args[0]}, false) }, } diff --git a/examples/vmwithcontracts/storage/recorder.go b/examples/vmwithcontracts/storage/recorder.go deleted file mode 100644 index d544fca78f..0000000000 --- a/examples/vmwithcontracts/storage/recorder.go +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. -// See the file LICENSE for licensing terms. - -package storage - -import ( - "context" - "errors" - - "github.com/ava-labs/avalanchego/database" - "github.com/ava-labs/avalanchego/utils/set" - - "github.com/ava-labs/hypersdk/state" -) - -type Recorder struct { - State state.Immutable - changedValues map[string][]byte - ReadState set.Set[string] - WriteState set.Set[string] -} - -func NewRecorder(db state.Immutable) *Recorder { - return &Recorder{State: db, changedValues: map[string][]byte{}} -} - -func (r *Recorder) Insert(_ context.Context, key []byte, value []byte) error { - stringKey := string(key) - r.WriteState.Add(stringKey) - r.changedValues[stringKey] = value - return nil -} - -func (r *Recorder) Remove(_ context.Context, key []byte) error { - stringKey := string(key) - r.WriteState.Add(stringKey) - r.changedValues[stringKey] = nil - return nil -} - -func (r *Recorder) GetValue(ctx context.Context, key []byte) (value []byte, err error) { - stringKey := string(key) - r.ReadState.Add(stringKey) - if value, ok := r.changedValues[stringKey]; ok { - if value == nil { - return nil, database.ErrNotFound - } - return value, nil - } - return r.State.GetValue(ctx, key) -} - -func (r *Recorder) GetStateKeys() state.Keys { - result := state.Keys{} - for key := range r.ReadState { - result.Add(key, state.Read) - } - for key := range r.WriteState { - if _, err := r.State.GetValue(context.Background(), []byte(key)); err != nil && errors.Is(err, database.ErrNotFound) { - if r.changedValues[key] == nil { - // not a real write since the key was not already present and is being deleted - continue - } - // wasn't found so needs to be allocated - result.Add(key, state.Allocate) - } - result.Add(key, state.Write) - } - return result -} diff --git a/examples/vmwithcontracts/tests/e2e/e2e_test.go b/examples/vmwithcontracts/tests/e2e/e2e_test.go index 1168084ea1..7a57cdc5a0 100644 --- a/examples/vmwithcontracts/tests/e2e/e2e_test.go +++ b/examples/vmwithcontracts/tests/e2e/e2e_test.go @@ -53,7 +53,7 @@ var _ = ginkgo.SynchronizedBeforeSuite(func() []byte { // Import HyperSDK e2e test coverage and inject VMWithContracts name // and workload factory to orchestrate the test. - he2e.SetWorkload(consts.Name, workloadFactory, parser, expectedABI) + he2e.SetWorkload(consts.Name, workloadFactory, expectedABI, parser, nil, nil) tc := e2e.NewTestContext() diff --git a/examples/vmwithcontracts/vm/client.go b/examples/vmwithcontracts/vm/client.go index 918905c661..da8bcdde82 100644 --- a/examples/vmwithcontracts/vm/client.go +++ b/examples/vmwithcontracts/vm/client.go @@ -5,7 +5,6 @@ package vm import ( "context" - "encoding/hex" "encoding/json" "strings" "time" @@ -13,12 +12,10 @@ import ( "github.com/ava-labs/hypersdk/api/jsonrpc" "github.com/ava-labs/hypersdk/chain" "github.com/ava-labs/hypersdk/codec" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/actions" "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" "github.com/ava-labs/hypersdk/examples/vmwithcontracts/storage" "github.com/ava-labs/hypersdk/genesis" "github.com/ava-labs/hypersdk/requester" - "github.com/ava-labs/hypersdk/state" "github.com/ava-labs/hypersdk/utils" ) @@ -114,16 +111,16 @@ func (p *Parser) Rules(_ int64) chain.Rules { return p.genesis.Rules } -func (p *Parser) ActionRegistry() chain.ActionRegistry { +func (p *Parser) ActionCodec() *codec.TypeParser[chain.Action] { return p.registry.ActionRegistry() } -func (p *Parser) AuthRegistry() chain.AuthRegistry { - return p.registry.AuthRegistry() +func (p *Parser) OutputCodec() *codec.TypeParser[codec.Typed] { + return p.registry.OutputRegistry() } -func (p *Parser) OutputRegistry() chain.OutputRegistry { - return p.registry.OutputRegistry() +func (p *Parser) AuthCodec() *codec.TypeParser[chain.Auth] { + return p.registry.AuthRegistry() } func (*Parser) StateManager() chain.StateManager { @@ -146,26 +143,3 @@ func CreateParser(genesisBytes []byte) (chain.Parser, error) { } return NewParser(&genesis, registry), nil } - -func (cli *JSONRPCClient) Simulate(ctx context.Context, callTx actions.Call, actor codec.Address) (state.Keys, uint64, error) { - resp := new(SimulateCallTxReply) - err := cli.requester.SendRequest( - ctx, - "simulateCallContractTx", - &SimulateCallTxArgs{CallTx: callTx, Actor: actor}, - resp, - ) - if err != nil { - return nil, 0, err - } - result := state.Keys{} - for _, entry := range resp.StateKeys { - hexBytes, err := hex.DecodeString(entry.HexKey) - if err != nil { - return nil, 0, err - } - - result.Add(string(hexBytes), state.Permissions(entry.Permissions)) - } - return result, resp.FuelConsumed, nil -} diff --git a/examples/vmwithcontracts/vm/server.go b/examples/vmwithcontracts/vm/server.go index 60f098d738..50adfd33ff 100644 --- a/examples/vmwithcontracts/vm/server.go +++ b/examples/vmwithcontracts/vm/server.go @@ -4,18 +4,13 @@ package vm import ( - "context" - "encoding/hex" "net/http" "github.com/ava-labs/hypersdk/api" "github.com/ava-labs/hypersdk/codec" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/actions" "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" "github.com/ava-labs/hypersdk/examples/vmwithcontracts/storage" "github.com/ava-labs/hypersdk/genesis" - "github.com/ava-labs/hypersdk/state" - "github.com/ava-labs/hypersdk/x/contracts/runtime" ) const JSONRPCEndpoint = "/vmwithcontractsapi" @@ -68,50 +63,3 @@ func (j *JSONRPCServer) Balance(req *http.Request, args *BalanceArgs, reply *Bal reply.Amount = balance return err } - -type SimulateCallTxArgs struct { - CallTx actions.Call `json:"callTx"` - Actor codec.Address `json:"actor"` -} - -type SimulateStateKey struct { - HexKey string `json:"hex"` - Permissions byte `json:"perm"` -} -type SimulateCallTxReply struct { - StateKeys []SimulateStateKey `json:"stateKeys"` - FuelConsumed uint64 `json:"fuel"` -} - -func (j *JSONRPCServer) SimulateCallContractTx(req *http.Request, args *SimulateCallTxArgs, reply *SimulateCallTxReply) (err error) { - stateKeys, fuelConsumed, err := j.simulate(req.Context(), args.CallTx, args.Actor) - if err != nil { - return err - } - reply.StateKeys = make([]SimulateStateKey, 0, len(stateKeys)) - for key, permission := range stateKeys { - reply.StateKeys = append(reply.StateKeys, SimulateStateKey{HexKey: hex.EncodeToString([]byte(key)), Permissions: byte(permission)}) - } - reply.FuelConsumed = fuelConsumed - return nil -} - -func (j *JSONRPCServer) simulate(ctx context.Context, t actions.Call, actor codec.Address) (state.Keys, uint64, error) { - currentState, err := j.vm.ImmutableState(ctx) - if err != nil { - return nil, 0, err - } - recorder := storage.NewRecorder(currentState) - startFuel := uint64(1000000000) - callInfo := &runtime.CallInfo{ - Contract: t.ContractAddress, - Actor: actor, - State: &storage.ContractStateManager{Mutable: recorder}, - FunctionName: t.Function, - Params: t.CallData, - Fuel: startFuel, - Value: t.Value, - } - _, err = wasmRuntime.CallContract(ctx, callInfo) - return recorder.GetStateKeys(), startFuel - callInfo.RemainingFuel(), err -} diff --git a/go.mod b/go.mod index d5973f6ca4..0072fe61c1 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( golang.org/x/crypto v0.22.0 golang.org/x/exp v0.0.0-20231127185646-65229373498e golang.org/x/sync v0.7.0 + golang.org/x/text v0.14.0 google.golang.org/grpc v1.62.0 google.golang.org/protobuf v1.34.2 gopkg.in/yaml.v2 v2.4.0 @@ -143,7 +144,6 @@ require ( golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/term v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.17.0 // indirect gonum.org/v1/gonum v0.11.0 // indirect diff --git a/internal/gossiper/dependencies.go b/internal/gossiper/dependencies.go index f9afed5ad1..555fe0cfc0 100644 --- a/internal/gossiper/dependencies.go +++ b/internal/gossiper/dependencies.go @@ -13,6 +13,7 @@ import ( "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/codec" ) type VM interface { @@ -26,8 +27,8 @@ type VM interface { IsValidator(context.Context, ids.NodeID) (bool, error) Logger() logging.Logger PreferredBlock(context.Context) (*chain.StatefulBlock, error) - ActionRegistry() chain.ActionRegistry - AuthRegistry() chain.AuthRegistry + ActionCodec() *codec.TypeParser[chain.Action] + AuthCodec() *codec.TypeParser[chain.Auth] NodeID() ids.NodeID Rules(int64) chain.Rules Submit(ctx context.Context, verify bool, txs []*chain.Transaction) []error diff --git a/internal/gossiper/manual.go b/internal/gossiper/manual.go index 134efd4924..c50e1cc01f 100644 --- a/internal/gossiper/manual.go +++ b/internal/gossiper/manual.go @@ -86,8 +86,8 @@ func (g *Manual) Force(ctx context.Context) error { } func (g *Manual) HandleAppGossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error { - actionRegistry, authRegistry := g.vm.ActionRegistry(), g.vm.AuthRegistry() - _, txs, err := chain.UnmarshalTxs(msg, initialCapacity, actionRegistry, authRegistry) + actionCodec, authCodec := g.vm.ActionCodec(), g.vm.AuthCodec() + _, txs, err := chain.UnmarshalTxs(msg, initialCapacity, actionCodec, authCodec) if err != nil { g.vm.Logger().Warn( "AppGossip provided invalid txs", diff --git a/internal/gossiper/proposer.go b/internal/gossiper/proposer.go index 8ef80b4de4..897199a742 100644 --- a/internal/gossiper/proposer.go +++ b/internal/gossiper/proposer.go @@ -163,8 +163,8 @@ func (g *Proposer) Force(ctx context.Context) error { } func (g *Proposer) HandleAppGossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error { - actionRegistry, authRegistry := g.vm.ActionRegistry(), g.vm.AuthRegistry() - authCounts, txs, err := chain.UnmarshalTxs(msg, initialCapacity, actionRegistry, authRegistry) + actionCodec, authCodec := g.vm.ActionCodec(), g.vm.AuthCodec() + authCounts, txs, err := chain.UnmarshalTxs(msg, initialCapacity, actionCodec, authCodec) if err != nil { g.vm.Logger().Warn( "received invalid txs", diff --git a/state/keys.go b/state/keys.go index f57d345aa3..13cb8d934c 100644 --- a/state/keys.go +++ b/state/keys.go @@ -3,7 +3,13 @@ package state -import "github.com/ava-labs/hypersdk/keys" +import ( + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/ava-labs/hypersdk/keys" +) const ( Read Permissions = 1 @@ -50,6 +56,40 @@ func (k Keys) ChunkSizes() ([]uint16, bool) { return chunks, true } +type permsJSON []string + +type keysJSON struct { + Perms [8]permsJSON +} + +func (k Keys) MarshalJSON() ([]byte, error) { + var keysJSON keysJSON + for key, perm := range k { + keysJSON.Perms[perm] = append(keysJSON.Perms[perm], hex.EncodeToString([]byte(key))) + } + return json.Marshal(keysJSON) +} + +func (k *Keys) UnmarshalJSON(b []byte) error { + var keysJSON keysJSON + if err := json.Unmarshal(b, &keysJSON); err != nil { + return err + } + for perm, keyList := range keysJSON.Perms { + if perm < int(None) || perm > int(All) { + return fmt.Errorf("invalid permission encoded in json %d", perm) + } + for _, encodedKey := range keyList { + key, err := hex.DecodeString(encodedKey) + if err != nil { + return err + } + (*k)[string(key)] = Permissions(perm) + } + } + return nil +} + // Has returns true if [p] has all the permissions that are contained in require func (p Permissions) Has(require Permissions) bool { return require&^p == 0 diff --git a/state/keys_test.go b/state/keys_test.go index 27733dd777..8e819f836a 100644 --- a/state/keys_test.go +++ b/state/keys_test.go @@ -4,6 +4,9 @@ package state import ( + "crypto/sha256" + "encoding/binary" + "math/rand" "slices" "testing" @@ -123,3 +126,58 @@ func TestHasPermissions(t *testing.T) { } } } + +func TestKeysMarshalingSimple(t *testing.T) { + require := require.New(t) + + // test with read permission. + keys := Keys{} + require.True(keys.Add("key1", Read)) + bytes, err := keys.MarshalJSON() + require.NoError(err) + require.Equal([]byte{0x7b, 0x22, 0x50, 0x65, 0x72, 0x6d, 0x73, 0x22, 0x3a, 0x5b, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x5b, 0x22, 0x36, 0x62, 0x36, 0x35, 0x37, 0x39, 0x33, 0x31, 0x22, 0x5d, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x5d, 0x7d}, bytes) + keys = Keys{} + require.NoError(keys.UnmarshalJSON(bytes)) + require.Len(keys, 1) + require.Equal(Read, keys["key1"]) + + // test with read+write permission. + keys = Keys{} + require.True(keys.Add("key2", Read|Write)) + bytes, err = keys.MarshalJSON() + require.NoError(err) + require.Equal([]byte{0x7b, 0x22, 0x50, 0x65, 0x72, 0x6d, 0x73, 0x22, 0x3a, 0x5b, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x5b, 0x22, 0x36, 0x62, 0x36, 0x35, 0x37, 0x39, 0x33, 0x32, 0x22, 0x5d, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x6e, 0x75, 0x6c, 0x6c, 0x5d, 0x7d}, bytes) + keys = Keys{} + require.NoError(keys.UnmarshalJSON(bytes)) + require.Len(keys, 1) + require.Equal(Read|Write, keys["key2"]) +} + +func (k Keys) compare(k2 Keys) bool { + if len(k) != len(k2) { + return false + } + for k1, v1 := range k { + if v2, has := k2[k1]; !has || v1 != v2 { + return false + } + } + return true +} + +func TestKeysMarshalingFuzz(t *testing.T) { + require := require.New(t) + rand := rand.New(rand.NewSource(0)) //nolint:gosec + for fuzzIteration := 0; fuzzIteration < 1000; fuzzIteration++ { + keys := Keys{} + for keyIdx := 0; keyIdx < rand.Int()%32; keyIdx++ { + key := sha256.Sum256(binary.BigEndian.AppendUint64(nil, uint64(keyIdx))) + keys.Add(string(key[:]), Permissions(rand.Int()%(int(All)+1))) + } + bytes, err := keys.MarshalJSON() + require.NoError(err) + decodedKeys := Keys{} + require.NoError(decodedKeys.UnmarshalJSON(bytes)) + require.True(keys.compare(decodedKeys)) + } +} diff --git a/state/recorder.go b/state/recorder.go new file mode 100644 index 0000000000..fba5cda6c6 --- /dev/null +++ b/state/recorder.go @@ -0,0 +1,97 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package state + +import ( + "context" + "errors" + + "github.com/ava-labs/avalanchego/database" +) + +// The Recorder wraps an [Immutable] state and records what keys are accessed +// and what permissions are required. +// Maintains same definition of required permissions as TStateView +type Recorder struct { + // State is the underlying [Immutable] object + state Immutable + stateKeys map[string][]byte + + changedValues map[string][]byte + keys Keys +} + +func NewRecorder(db Immutable) *Recorder { + return &Recorder{state: db, changedValues: map[string][]byte{}, stateKeys: map[string][]byte{}, keys: Keys{}} +} + +func (r *Recorder) checkState(ctx context.Context, key []byte) ([]byte, error) { + if val, has := r.stateKeys[string(key)]; has { + return val, nil + } + value, err := r.state.GetValue(ctx, key) + if err == nil { + // no error, key found. + r.stateKeys[string(key)] = value + return value, nil + } + + if errors.Is(err, database.ErrNotFound) { + r.stateKeys[string(key)] = nil + err = nil + } + return nil, err +} + +func (r *Recorder) Insert(ctx context.Context, key []byte, value []byte) error { + stringKey := string(key) + + stateKeyVal, err := r.checkState(ctx, key) + if err != nil { + return err + } + + if stateKeyVal != nil { + // underlying storage already has that key. + r.keys[stringKey] |= Write + } else { + // underlying storage doesn't have that key. + r.keys[stringKey] |= Allocate | Write + } + + // save the updated value. + r.changedValues[stringKey] = value + return nil +} + +func (r *Recorder) Remove(_ context.Context, key []byte) error { + stringKey := string(key) + r.keys[stringKey] |= Write + r.changedValues[stringKey] = nil + return nil +} + +func (r *Recorder) GetValue(ctx context.Context, key []byte) (value []byte, err error) { + stringKey := string(key) + + stateKeyVal, err := r.checkState(ctx, key) + if err != nil { + return nil, err + } + r.keys[stringKey] |= Read + if value, ok := r.changedValues[stringKey]; ok { + if value == nil { // value was removed. + return nil, database.ErrNotFound + } + return value, nil + } + if stateKeyVal == nil { // no such key exist. + return nil, database.ErrNotFound + } + return stateKeyVal, nil +} + +func (r *Recorder) GetStateKeys() Keys { + return r.keys +} diff --git a/state/recorder_test.go b/state/recorder_test.go new file mode 100644 index 0000000000..bfcf3b21cd --- /dev/null +++ b/state/recorder_test.go @@ -0,0 +1,247 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package state_test + +import ( + "context" + "crypto/rand" + "testing" + + "github.com/ava-labs/avalanchego/database" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/hypersdk/keys" + "github.com/ava-labs/hypersdk/state" + "github.com/ava-labs/hypersdk/state/tstate" +) + +func randomNewKey() []byte { + randNewKey := make([]byte, 30, 32) + _, err := rand.Read(randNewKey) + if err != nil { + panic(err) + } + return keys.EncodeChunks(randNewKey, 1) +} + +func randomizeView(tstate *tstate.TState, keyCount int) (*tstate.TStateView, [][]byte, map[string]state.Permissions, map[string][]byte) { + keys := make([][]byte, keyCount) + values := make([][32]byte, keyCount) + storage := map[string][]byte{} + scope := map[string]state.Permissions{} + for i := 0; i < keyCount; i++ { + keys[i] = randomNewKey() + _, err := rand.Read(values[i][:]) + if err != nil { + panic(err) + } + storage[string(keys[i])] = values[i][:] + scope[string(keys[i])] = state.All + } + // create new view + return tstate.NewView(scope, storage), keys, scope, storage +} + +func TestRecorderInnerFuzz(t *testing.T) { + tstateObj := tstate.New(1000) + require := require.New(t) + + var ( + stateView *tstate.TStateView + keys [][]byte + scope map[string]state.Permissions + removedKeys map[string]bool + ) + + pickExistingKeyAtRandom := func() []byte { + randKey := make([]byte, 1) + _, err := rand.Read(randKey) + require.NoError(err) + randKey[0] %= byte(len(keys)) + for removedKeys[string(keys[randKey[0]])] { + _, err := rand.Read(randKey) + randKey[0] %= byte(len(keys)) + require.NoError(err) + } + return keys[randKey[0]] + } + for i := 0; i < 10000; i++ { + stateView, keys, scope, _ = randomizeView(tstateObj, 32) + removedKeys = map[string]bool{} + // wrap with recorder. + recorder := state.NewRecorder(stateView) + for j := 0; j <= 32; j++ { + op := make([]byte, 1) + _, err := rand.Read(op) + require.NoError(err) + switch op[0] % 6 { + case 0: // insert into existing entry + randKey := pickExistingKeyAtRandom() + err := recorder.Insert(context.Background(), randKey, []byte{1, 2, 3, 4}) + require.NoError(err) + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Write)) + case 1: // insert into new entry + randNewKey := randomNewKey() + // add the new key to the scope + scope[string(randNewKey)] = state.Allocate | state.Write + err := recorder.Insert(context.Background(), randNewKey, []byte{1, 2, 3, 4}) + require.NoError(err) + require.True(recorder.GetStateKeys()[string(randNewKey)].Has(state.Allocate | state.Write)) + keys = append(keys, randNewKey) + case 2: // remove existing entry + randKey := pickExistingKeyAtRandom() + err := recorder.Remove(context.Background(), randKey) + require.NoError(err) + removedKeys[string(randKey)] = true + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Write)) + case 3: // remove non existing entry + randKey := randomNewKey() + err := recorder.Remove(context.Background(), randKey) + require.NoError(err) + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Write)) + case 4: // get value of existing entry + randKey := pickExistingKeyAtRandom() + val, err := recorder.GetValue(context.Background(), randKey) + require.NoError(err) + require.NotEmpty(val) + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Read)) + case 5: // get value of non existing entry + randKey := randomNewKey() + // add the new key to the scope + scope[string(randKey)] = state.Read + value, err := recorder.GetValue(context.Background(), randKey) + require.ErrorIs(err, database.ErrNotFound) + require.Empty(value) + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Read)) + } + } + } +} + +type testingReadonlyDatasource struct { + storage map[string][]byte +} + +func (c *testingReadonlyDatasource) GetValue(_ context.Context, key []byte) (value []byte, err error) { + if v, has := c.storage[string(key)]; has { + return v, nil + } + return nil, database.ErrNotFound +} + +func TestRecorderSideBySideFuzz(t *testing.T) { + tstateObj := tstate.New(1000) + require := require.New(t) + + var ( + stateView *tstate.TStateView + keys [][]byte + scope map[string]state.Permissions + removedKeys map[string]bool + storage map[string][]byte + ) + + pickExistingKeyAtRandom := func() []byte { + randKey := make([]byte, 1) + _, err := rand.Read(randKey) + require.NoError(err) + randKey[0] %= byte(len(keys)) + for removedKeys[string(keys[randKey[0]])] { + _, err := rand.Read(randKey) + randKey[0] %= byte(len(keys)) + require.NoError(err) + } + return keys[randKey[0]] + } + randomValue := func() []byte { + randVal := make([]byte, 32) + _, err := rand.Read(randVal) + require.NoError(err) + return randVal + } + + for i := 0; i < 10000; i++ { + stateView, keys, scope, storage = randomizeView(tstateObj, 32) + removedKeys = map[string]bool{} + // wrap with recorder. + recorder := state.NewRecorder(&testingReadonlyDatasource{storage}) + for j := 0; j <= 32; j++ { + op := make([]byte, 1) + _, err := rand.Read(op) + require.NoError(err) + switch op[0] % 6 { + case 0: // insert into existing entry + randKey := pickExistingKeyAtRandom() + randVal := randomValue() + + err := recorder.Insert(context.Background(), randKey, randVal) + require.NoError(err) + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Write)) + + err = stateView.Insert(context.Background(), randKey, randVal) + require.NoError(err) + case 1: // insert into new entry + randNewKey := randomNewKey() + randVal := randomValue() + + err := recorder.Insert(context.Background(), randNewKey, randVal) + require.NoError(err) + require.True(recorder.GetStateKeys()[string(randNewKey)].Has(state.Allocate | state.Write)) + + // add the new key to the scope + scope[string(randNewKey)] = state.Write | state.Allocate + err = stateView.Insert(context.Background(), randNewKey, randVal) + require.NoError(err) + + keys = append(keys, randNewKey) + case 2: // remove existing entry + randKey := pickExistingKeyAtRandom() + + err := recorder.Remove(context.Background(), randKey) + require.NoError(err) + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Write)) + + err = stateView.Remove(context.Background(), randKey) + require.NoError(err) + + removedKeys[string(randKey)] = true + case 3: // remove non existing entry + randKey := randomNewKey() + + err := recorder.Remove(context.Background(), randKey) + require.NoError(err) + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Write)) + + // add the new key to the scope + scope[string(randKey)] = state.Write + err = stateView.Remove(context.Background(), randKey) + require.NoError(err) + case 4: // get value of existing entry + randKey := pickExistingKeyAtRandom() + + val, err := recorder.GetValue(context.Background(), randKey) + require.NoError(err) + require.NotEmpty(val) + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Read)) + + val, err = stateView.GetValue(context.Background(), randKey) + require.NoError(err) + require.NotEmpty(val) + case 5: // get value of non existing entry + randKey := randomNewKey() + + value, err := recorder.GetValue(context.Background(), randKey) + require.ErrorIs(err, database.ErrNotFound) + require.Empty(value) + require.True(recorder.GetStateKeys()[string(randKey)].Has(state.Read)) + + // add the new key to the scope + scope[string(randKey)] = state.Read + value, err = stateView.GetValue(context.Background(), randKey) + require.ErrorIs(err, database.ErrNotFound) + require.Empty(value) + } + } + } +} diff --git a/tests/e2e/e2e.go b/tests/e2e/e2e.go index 085b4cb5e5..994ae1aa87 100644 --- a/tests/e2e/e2e.go +++ b/tests/e2e/e2e.go @@ -18,8 +18,10 @@ import ( "github.com/ava-labs/hypersdk/abi" "github.com/ava-labs/hypersdk/api/jsonrpc" "github.com/ava-labs/hypersdk/api/state" + "github.com/ava-labs/hypersdk/auth" "github.com/ava-labs/hypersdk/chain" "github.com/ava-labs/hypersdk/tests/workload" + "github.com/ava-labs/hypersdk/throughput" "github.com/ava-labs/hypersdk/utils" ginkgo "github.com/onsi/ginkgo/v2" @@ -30,13 +32,17 @@ var ( txWorkloadFactory workload.TxWorkloadFactory parser chain.Parser expectedABI abi.ABI + spamKey *auth.PrivateKey + spamHelper throughput.SpamHelper ) -func SetWorkload(name string, factory workload.TxWorkloadFactory, chainParser chain.Parser, abi abi.ABI) { +func SetWorkload(name string, factory workload.TxWorkloadFactory, abi abi.ABI, chainParser chain.Parser, sh throughput.SpamHelper, key *auth.PrivateKey) { vmName = name txWorkloadFactory = factory parser = chainParser expectedABI = abi + spamHelper = sh + spamKey = key } var _ = ginkgo.Describe("[HyperSDK APIs]", func() { @@ -109,6 +115,30 @@ var _ = ginkgo.Describe("[HyperSDK Tx Workloads]", func() { }) }) +var _ = ginkgo.Describe("[HyperSDK Spam Workloads]", func() { + ginkgo.It("Spam Workload", func() { + if spamKey == nil || spamHelper == nil { + return + } + + tc := e2e.NewTestContext() + require := require.New(tc) + blockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(vmName).Chains[0].ChainID + uris := getE2EURIs(tc, blockchainID) + key := spamKey + + err := spamHelper.CreateClient(uris[0]) + require.NoError(err) + + spamConfig := throughput.NewDefaultConfig(uris, key) + spammer, err := throughput.NewSpammer(spamConfig, spamHelper) + require.NoError(err) + + err = spammer.Spam(tc.DefaultContext(), spamHelper, true, "AVAX") + require.NoError(err) + }) +}) + var _ = ginkgo.Describe("[HyperSDK Syncing]", func() { ginkgo.It("[Sync]", func() { tc := e2e.NewTestContext() diff --git a/tests/integration/integration.go b/tests/integration/integration.go index b5d787e33e..0640b20a3f 100644 --- a/tests/integration/integration.go +++ b/tests/integration/integration.go @@ -296,8 +296,8 @@ var _ = ginkgo.Describe("[HyperSDK APIs]", func() { ginkgo.It("GetABI", func() { ginkgo.By("Gets ABI") - actionRegistry, outputRegistry := instances[0].vm.ActionRegistry(), instances[0].vm.OutputRegistry() - expectedABI, err := abi.NewABI((*actionRegistry).GetRegisteredTypes(), (*outputRegistry).GetRegisteredTypes()) + actionCodec, outputCodec := instances[0].vm.ActionCodec(), instances[0].vm.OutputCodec() + expectedABI, err := abi.NewABI(actionCodec.GetRegisteredTypes(), (*outputCodec).GetRegisteredTypes()) require.NoError(err) workload.GetABI(ctx, require, uris, expectedABI) @@ -346,7 +346,7 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { }) ginkgo.By("skip invalid time", func() { - tx := chain.NewTx( + tx := chain.NewTxData( &chain.Base{ ChainID: instances[0].chainID, Timestamp: 1, @@ -360,9 +360,12 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { require.NoError(err) auth, err := authFactory.Sign(unsignedTxBytes) require.NoError(err) - tx.Auth = auth + signedTx := chain.Transaction{ + TransactionData: *tx, + Auth: auth, + } p := codec.NewWriter(0, consts.MaxInt) // test codec growth - require.NoError(tx.Marshal(p)) + require.NoError(signedTx.Marshal(p)) require.NoError(p.Err()) _, err = instances[0].cli.SubmitTx( context.Background(), diff --git a/throughput/config.go b/throughput/config.go new file mode 100644 index 0000000000..bff2524e1a --- /dev/null +++ b/throughput/config.go @@ -0,0 +1,59 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package throughput + +import "github.com/ava-labs/hypersdk/auth" + +type Config struct { + uris []string + key *auth.PrivateKey + sZipf float64 + vZipf float64 + txsPerSecond int + minTxsPerSecond int + txsPerSecondStep int + numClients int + numAccounts int +} + +func NewDefaultConfig( + uris []string, + key *auth.PrivateKey, +) *Config { + return &Config{ + uris: uris, + key: key, + sZipf: 1.01, + vZipf: 2.7, + txsPerSecond: 500, + minTxsPerSecond: 100, + txsPerSecondStep: 200, + numClients: 10, + numAccounts: 25, + } +} + +func NewConfig( + uris []string, + key *auth.PrivateKey, + sZipf float64, + vZipf float64, + txsPerSecond int, + minTxsPerSecond int, + txsPerSecondStep int, + numClients int, + numAccounts int, +) *Config { + return &Config{ + uris, + key, + sZipf, + vZipf, + txsPerSecond, + minTxsPerSecond, + txsPerSecondStep, + numClients, + numAccounts, + } +} diff --git a/throughput/errors.go b/throughput/errors.go new file mode 100644 index 0000000000..8c210cbd8d --- /dev/null +++ b/throughput/errors.go @@ -0,0 +1,8 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package throughput + +import "errors" + +var ErrTxFailed = errors.New("tx failed on-chain") diff --git a/throughput/helper.go b/throughput/helper.go new file mode 100644 index 0000000000..b616ba6310 --- /dev/null +++ b/throughput/helper.go @@ -0,0 +1,40 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package throughput + +import ( + "context" + + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/codec" +) + +type SpamHelper interface { + // CreateAccount generates a new account and returns the [PrivateKey]. + // + // The spammer tracks all created accounts and orchestrates the return of funds + // sent to any created accounts on shutdown. If the spammer exits ungracefully, + // any funds sent to created accounts will be lost unless they are persisted by + // the [SpamHelper] implementation. + CreateAccount() (*auth.PrivateKey, error) + + // CreateClient instructs the [SpamHelper] to create and persist a VM-specific + // JSONRPC client. + // + // This client is used to retrieve the [chain.Parser] and the balance + // of arbitrary addresses. + // + // TODO: consider making these functions part of the required JSONRPC + // interface for the HyperSDK. + CreateClient(uri string) error + GetParser(ctx context.Context) (chain.Parser, error) + LookupBalance(address codec.Address) (uint64, error) + + // GetTransfer returns a list of actions that sends [amount] to a given [address]. + // + // Memo is used to ensure that each transaction is unique (even if between the same + // sender and receiver for the same amount). + GetTransfer(address codec.Address, amount uint64, memo []byte) []chain.Action +} diff --git a/throughput/issuer.go b/throughput/issuer.go new file mode 100644 index 0000000000..5f5443040b --- /dev/null +++ b/throughput/issuer.go @@ -0,0 +1,125 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package throughput + +import ( + "context" + "fmt" + "sync" + "time" + + "golang.org/x/exp/rand" + + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/api/ws" + "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/pubsub" + "github.com/ava-labs/hypersdk/utils" +) + +type issuer struct { + i int + uri string + parser chain.Parser + + // TODO: clean up potential race conditions here. + l sync.Mutex + cli *jsonrpc.JSONRPCClient + ws *ws.WebSocketClient + outstandingTxs int + abandoned error + + // injected from the spammer + tracker *tracker +} + +func (i *issuer) Start(ctx context.Context) { + i.tracker.issuerWg.Add(1) + go func() { + for { + _, wsErr, result, err := i.ws.ListenTx(context.TODO()) + if err != nil { + return + } + i.l.Lock() + i.outstandingTxs-- + i.l.Unlock() + i.tracker.inflight.Add(-1) + i.tracker.logResult(result, wsErr) + } + }() + go func() { + defer func() { + _ = i.ws.Close() + i.tracker.issuerWg.Done() + }() + + <-ctx.Done() + start := time.Now() + for time.Since(start) < issuerShutdownTimeout { + if i.ws.Closed() { + return + } + i.l.Lock() + outstanding := i.outstandingTxs + i.l.Unlock() + if outstanding == 0 { + return + } + utils.Outf("{{orange}}waiting for issuer %d to finish:{{/}} %d\n", i.i, outstanding) + time.Sleep(time.Second) + } + utils.Outf("{{orange}}issuer %d shutdown timeout{{/}}\n", i.i) + }() +} + +func (i *issuer) Send(ctx context.Context, actions []chain.Action, factory chain.AuthFactory, feePerTx uint64) error { + // Construct transaction + _, tx, err := i.cli.GenerateTransactionManual(i.parser, actions, factory, feePerTx) + if err != nil { + utils.Outf("{{orange}}failed to generate tx:{{/}} %v\n", err) + return fmt.Errorf("failed to generate tx: %w", err) + } + + // Increase outstanding txs for issuer + i.l.Lock() + i.outstandingTxs++ + i.l.Unlock() + i.tracker.inflight.Add(1) + + // Register transaction and recover upon failure + if err := i.ws.RegisterTx(tx); err != nil { + i.l.Lock() + if i.ws.Closed() { + if i.abandoned != nil { + i.l.Unlock() + return i.abandoned + } + + // Attempt to recreate issuer + utils.Outf("{{orange}}re-creating issuer:{{/}} %d {{orange}}uri:{{/}} %s\n", i.i, i.uri) + ws, err := ws.NewWebSocketClient(i.uri, ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) // we write the max read + if err != nil { + i.abandoned = err + utils.Outf("{{orange}}could not re-create closed issuer:{{/}} %v\n", err) + i.l.Unlock() + return err + } + i.ws = ws + i.l.Unlock() + + i.Start(ctx) + utils.Outf("{{green}}re-created closed issuer:{{/}} %d\n", i.i) + } + + // If issuance fails during retry, we should fail + return i.ws.RegisterTx(tx) + } + return nil +} + +func getRandomIssuer(issuers []*issuer) *issuer { + index := rand.Int() % len(issuers) + return issuers[index] +} diff --git a/throughput/pacer.go b/throughput/pacer.go new file mode 100644 index 0000000000..ed199518f0 --- /dev/null +++ b/throughput/pacer.go @@ -0,0 +1,59 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package throughput + +import ( + "context" + "fmt" + + "github.com/ava-labs/hypersdk/api/ws" + "github.com/ava-labs/hypersdk/chain" +) + +type pacer struct { + ws *ws.WebSocketClient + + inflight chan struct{} + done chan error +} + +func (p *pacer) Run(ctx context.Context, max int) { + p.inflight = make(chan struct{}, max) + p.done = make(chan error) + + for range p.inflight { + _, wsErr, result, err := p.ws.ListenTx(ctx) + if err != nil { + p.done <- err + return + } + if wsErr != nil { + p.done <- wsErr + return + } + if !result.Success { + // Should never happen + p.done <- fmt.Errorf("%w: %s", ErrTxFailed, result.Error) + return + } + } + p.done <- nil +} + +func (p *pacer) Add(tx *chain.Transaction) error { + if err := p.ws.RegisterTx(tx); err != nil { + return err + } + select { + case p.inflight <- struct{}{}: + return nil + case err := <-p.done: + return err + } +} + +func (p *pacer) Wait() error { + close(p.inflight) + return <-p.done +} diff --git a/throughput/spam.go b/throughput/spam.go new file mode 100644 index 0000000000..7a06486528 --- /dev/null +++ b/throughput/spam.go @@ -0,0 +1,449 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package throughput + +import ( + "context" + "fmt" + "math/rand" + "os" + "os/signal" + "runtime" + "sync" + "syscall" + "time" + + "github.com/ava-labs/avalanchego/utils/set" + "golang.org/x/sync/errgroup" + + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/api/ws" + "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/fees" + "github.com/ava-labs/hypersdk/pubsub" + "github.com/ava-labs/hypersdk/utils" +) + +const ( + amountToTransfer = 1 + pendingTargetMultiplier = 10 + successfulRunsToIncreaseTarget = 10 + failedRunsToDecreaseTarget = 5 + + issuerShutdownTimeout = 60 * time.Second +) + +// TODO: remove the use of global variables +var ( + maxConcurrency = runtime.NumCPU() +) + +type Spammer struct { + uris []string + key *auth.PrivateKey + balance uint64 + + // Zipf distribution parameters + zipfSeed *rand.Rand + sZipf float64 + vZipf float64 + + // TPS parameters + txsPerSecond int + minTxsPerSecond int + txsPerSecondStep int + numClients int // Number of clients per uri node + + // Number of accounts + numAccounts int + + // keep track of variables shared across issuers + tracker *tracker +} + +func NewSpammer(sc *Config, sh SpamHelper) (*Spammer, error) { + // Log Zipf participants + zipfSeed := rand.New(rand.NewSource(0)) //nolint:gosec + tracker := &tracker{} + balance, err := sh.LookupBalance(sc.key.Address) + if err != nil { + return nil, err + } + + return &Spammer{ + uris: sc.uris, + key: sc.key, + balance: balance, + zipfSeed: zipfSeed, + sZipf: sc.sZipf, + vZipf: sc.vZipf, + + txsPerSecond: sc.txsPerSecond, + minTxsPerSecond: sc.minTxsPerSecond, + txsPerSecondStep: sc.txsPerSecondStep, + numClients: sc.numClients, + numAccounts: sc.numAccounts, + + tracker: tracker, + }, nil +} + +// Spam tests the throughput of the network by sending transactions using +// multiple accounts and clients. It first distributes funds to the accounts +// and then sends transactions between the accounts. It returns the funds to +// the original account after the test is complete. +// [sh] injects the necessary functions to interact with the network. +// [terminate] if true, the spammer will stop after reaching the target TPS. +// [symbol] and [decimals] are used to format the output. +func (s *Spammer) Spam(ctx context.Context, sh SpamHelper, terminate bool, symbol string) error { + // log distribution + s.logZipf(s.zipfSeed) + + // new JSONRPC client + cli := jsonrpc.NewJSONRPCClient(s.uris[0]) + + factory, err := auth.GetFactory(s.key) + if err != nil { + return err + } + + // Compute max units + parser, err := sh.GetParser(ctx) + if err != nil { + return err + } + actions := sh.GetTransfer(s.key.Address, 0, s.tracker.uniqueBytes()) + maxUnits, err := chain.EstimateUnits(parser.Rules(time.Now().UnixMilli()), actions, factory) + if err != nil { + return err + } + + unitPrices, err := cli.UnitPrices(ctx, false) + if err != nil { + return err + } + feePerTx, err := fees.MulSum(unitPrices, maxUnits) + if err != nil { + return err + } + + // distribute funds + accounts, funds, factories, err := s.distributeFunds(ctx, cli, parser, feePerTx, sh) + if err != nil { + return err + } + + // create issuers + issuers, err := s.createIssuers(parser) + if err != nil { + return err + } + + cctx, cancel := context.WithCancel(ctx) + defer cancel() + + for _, issuer := range issuers { + issuer.Start(cctx) + } + + // set logging + s.tracker.logState(cctx, issuers[0].cli) + + // broadcast transactions + s.broadcast(cctx, cancel, sh, accounts, funds, factories, issuers, feePerTx, terminate) + + maxUnits, err = chain.EstimateUnits(parser.Rules(time.Now().UnixMilli()), actions, factory) + if err != nil { + return err + } + return s.returnFunds(ctx, cli, parser, maxUnits, sh, accounts, factories, funds, symbol) +} + +func (s Spammer) broadcast( + ctx context.Context, + cancel context.CancelFunc, + sh SpamHelper, + accounts []*auth.PrivateKey, + + funds map[codec.Address]uint64, + factories []chain.AuthFactory, + issuers []*issuer, + + feePerTx uint64, + terminate bool, +) { + // make sure we can exit gracefully & return funds + signals := make(chan os.Signal, 2) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + + var ( + // Do not call this function concurrently (math.Rand is not safe for concurrent use) + z = rand.NewZipf(s.zipfSeed, s.sZipf, s.vZipf, uint64(s.numAccounts)-1) + fundsL = sync.Mutex{} + + it = time.NewTimer(0) + currentTarget = min(s.txsPerSecond, s.minTxsPerSecond) + consecutiveUnderBacklog int + consecutiveAboveBacklog int + + stop bool + ) + utils.Outf("{{cyan}}initial target tps:{{/}} %d\n", currentTarget) + for !stop { + select { + case <-it.C: + start := time.Now() + + // Check to see if we should wait for pending txs + if int64(currentTarget)+s.tracker.inflight.Load() > int64(currentTarget*pendingTargetMultiplier) { + consecutiveUnderBacklog = 0 + consecutiveAboveBacklog++ + if consecutiveAboveBacklog >= failedRunsToDecreaseTarget { + if currentTarget > s.txsPerSecondStep { + currentTarget -= s.txsPerSecondStep + utils.Outf("{{cyan}}skipping issuance because large backlog detected, decreasing target tps:{{/}} %d\n", currentTarget) + } else { + utils.Outf("{{cyan}}skipping issuance because large backlog detected, cannot decrease target{{/}}\n") + } + consecutiveAboveBacklog = 0 + } + it.Reset(1 * time.Second) + break + } + + // Issue txs + g := &errgroup.Group{} + g.SetLimit(maxConcurrency) + for i := 0; i < currentTarget; i++ { + senderIndex, recipientIndex := z.Uint64(), z.Uint64() + sender := accounts[senderIndex] + if recipientIndex == senderIndex { + if recipientIndex == uint64(s.numAccounts-1) { + recipientIndex-- + } else { + recipientIndex++ + } + } + recipient := accounts[recipientIndex].Address + issuer := getRandomIssuer(issuers) + g.Go(func() error { + factory := factories[senderIndex] + fundsL.Lock() + balance := funds[sender.Address] + if feePerTx > balance { + fundsL.Unlock() + utils.Outf("{{orange}}tx has insufficient funds:{{/}} %s\n", sender.Address) + return fmt.Errorf("%s has insufficient funds", sender.Address) + } + funds[sender.Address] = balance - feePerTx - amountToTransfer + funds[recipient] += amountToTransfer + fundsL.Unlock() + + // Send transaction + actions := sh.GetTransfer(recipient, amountToTransfer, s.tracker.uniqueBytes()) + return issuer.Send(ctx, actions, factory, feePerTx) + }) + } + + // Wait for txs to finish + if err := g.Wait(); err != nil { + // We don't return here because we want to return funds + utils.Outf("{{orange}}broadcast loop error:{{/}} %v\n", err) + stop = true + break + } + + // Determine how long to sleep + dur := time.Since(start) + sleep := max(float64(consts.MillisecondsPerSecond-dur.Milliseconds()), 0) + it.Reset(time.Duration(sleep) * time.Millisecond) + + // Check to see if we should increase target + consecutiveAboveBacklog = 0 + consecutiveUnderBacklog++ + // once desired TPS is reached, stop the spammer + if terminate && currentTarget == s.txsPerSecond && consecutiveUnderBacklog >= successfulRunsToIncreaseTarget { + utils.Outf("{{green}}reached target tps:{{/}} %d\n", currentTarget) + // Cancel the context to stop the issuers + cancel() + } else if consecutiveUnderBacklog >= successfulRunsToIncreaseTarget && currentTarget < s.txsPerSecond { + currentTarget = min(currentTarget+s.txsPerSecondStep, s.txsPerSecond) + utils.Outf("{{cyan}}increasing target tps:{{/}} %d\n", currentTarget) + consecutiveUnderBacklog = 0 + } + case <-ctx.Done(): + stop = true + utils.Outf("{{yellow}}context canceled{{/}}\n") + case <-signals: + stop = true + utils.Outf("{{yellow}}exiting broadcast loop{{/}}\n") + cancel() + } + } + + // Wait for all issuers to finish + utils.Outf("{{yellow}}waiting for issuers to return{{/}}\n") + s.tracker.issuerWg.Wait() +} + +func (s *Spammer) logZipf(zipfSeed *rand.Rand) { + zz := rand.NewZipf(zipfSeed, s.sZipf, s.vZipf, uint64(s.numAccounts)-1) + trials := s.txsPerSecond * 60 * 2 // sender/receiver + unique := set.NewSet[uint64](trials) + for i := 0; i < trials; i++ { + unique.Add(zz.Uint64()) + } + utils.Outf("{{blue}}unique participants expected every 60s:{{/}} %d\n", unique.Len()) +} + +// createIssuer creates an [numClients] transaction issuers for each URI in [uris] +func (s *Spammer) createIssuers(parser chain.Parser) ([]*issuer, error) { + issuers := []*issuer{} + + for i := 0; i < len(s.uris); i++ { + for j := 0; j < s.numClients; j++ { + cli := jsonrpc.NewJSONRPCClient(s.uris[i]) + webSocketClient, err := ws.NewWebSocketClient(s.uris[i], ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) // we write the max read + if err != nil { + return nil, err + } + issuer := &issuer{ + i: len(issuers), + cli: cli, + ws: webSocketClient, + parser: parser, + uri: s.uris[i], + tracker: s.tracker, + } + issuers = append(issuers, issuer) + } + } + return issuers, nil +} + +func (s *Spammer) distributeFunds(ctx context.Context, cli *jsonrpc.JSONRPCClient, parser chain.Parser, feePerTx uint64, sh SpamHelper) ([]*auth.PrivateKey, map[codec.Address]uint64, []chain.AuthFactory, error) { + withholding := feePerTx * uint64(s.numAccounts) + if s.balance < withholding { + return nil, nil, nil, fmt.Errorf("insufficient funds (have=%d need=%d)", s.balance, withholding) + } + + distAmount := (s.balance - withholding) / uint64(s.numAccounts) + + utils.Outf("{{yellow}}distributing funds to each account{{/}}\n") + + funds := map[codec.Address]uint64{} + accounts := make([]*auth.PrivateKey, s.numAccounts) + factories := make([]chain.AuthFactory, s.numAccounts) + + factory, err := auth.GetFactory(s.key) + if err != nil { + return nil, nil, nil, err + } + + webSocketClient, err := ws.NewWebSocketClient(s.uris[0], ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) // we write the max read + if err != nil { + return nil, nil, nil, err + } + p := &pacer{ws: webSocketClient} + go p.Run(ctx, s.minTxsPerSecond) + // TODO: we sleep here because occasionally the pacer will hang. Potentially due to + // p.wait() closing the inflight channel before the tx is registered/sent. Debug more. + time.Sleep(3 * time.Second) + for i := 0; i < s.numAccounts; i++ { + // Create account + pk, err := sh.CreateAccount() + if err != nil { + return nil, nil, nil, err + } + accounts[i] = pk + f, err := auth.GetFactory(pk) + if err != nil { + return nil, nil, nil, err + } + factories[i] = f + + // Send funds + actions := sh.GetTransfer(pk.Address, distAmount, s.tracker.uniqueBytes()) + _, tx, err := cli.GenerateTransactionManual(parser, actions, factory, feePerTx) + if err != nil { + return nil, nil, nil, err + } + if err := p.Add(tx); err != nil { + return nil, nil, nil, fmt.Errorf("%w: failed to register tx", err) + } + funds[pk.Address] = distAmount + + // Log progress + if i%250 == 0 && i > 0 { + utils.Outf("{{yellow}}issued transfer to %d accounts{{/}}\n", i) + } + } + if err := p.Wait(); err != nil { + return nil, nil, nil, err + } + utils.Outf("{{yellow}}distributed funds to %d accounts{{/}}\n", s.numAccounts) + + return accounts, funds, factories, nil +} + +func (s *Spammer) returnFunds(ctx context.Context, cli *jsonrpc.JSONRPCClient, parser chain.Parser, maxUnits fees.Dimensions, sh SpamHelper, accounts []*auth.PrivateKey, factories []chain.AuthFactory, funds map[codec.Address]uint64, symbol string) error { + // Return funds + unitPrices, err := cli.UnitPrices(ctx, false) + if err != nil { + return err + } + feePerTx, err := fees.MulSum(unitPrices, maxUnits) + if err != nil { + return err + } + utils.Outf("{{yellow}}returning funds to %s{{/}}\n", s.key.Address) + var returnedBalance uint64 + + webSocketClient, err := ws.NewWebSocketClient(s.uris[0], ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) // we write the max read + if err != nil { + return err + } + p := &pacer{ws: webSocketClient} + go p.Run(ctx, s.minTxsPerSecond) + // TODO: we sleep here because occasionally the pacer will hang. Potentially due to + // p.wait() closing the inflight channel before the tx is registered/sent. Debug more. + time.Sleep(3 * time.Second) + for i := 0; i < s.numAccounts; i++ { + // Determine if we should return funds + balance := funds[accounts[i].Address] + if feePerTx > balance { + continue + } + + // Send funds + returnAmt := balance - feePerTx + actions := sh.GetTransfer(s.key.Address, returnAmt, s.tracker.uniqueBytes()) + _, tx, err := cli.GenerateTransactionManual(parser, actions, factories[i], feePerTx) + if err != nil { + return err + } + if err := p.Add(tx); err != nil { + return err + } + returnedBalance += returnAmt + + if i%250 == 0 && i > 0 { + utils.Outf("{{yellow}}checked %d accounts for fund return{{/}}\n", i) + } + utils.Outf("{{yellow}}returning funds to %s:{{/}} %s %s\n", accounts[i].Address, utils.FormatBalance(returnAmt), symbol) + } + if err := p.Wait(); err != nil { + utils.Outf("{{orange}}failed to return funds:{{/}} %v\n", err) + return err + } + utils.Outf( + "{{yellow}}returned funds:{{/}} %s %s\n", + utils.FormatBalance(returnedBalance), + symbol, + ) + return nil +} diff --git a/throughput/tracker.go b/throughput/tracker.go new file mode 100644 index 0000000000..c0fc520a7d --- /dev/null +++ b/throughput/tracker.go @@ -0,0 +1,88 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package throughput + +import ( + "context" + "encoding/binary" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/api/ws" + "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/utils" +) + +type tracker struct { + issuerWg sync.WaitGroup + inflight atomic.Int64 + + l sync.Mutex + confirmedTxs int + totalTxs int + + sent atomic.Int64 +} + +func (t *tracker) logResult( + result *chain.Result, + wsErr error, +) { + t.l.Lock() + if result != nil { + if result.Success { + t.confirmedTxs++ + } else { + utils.Outf("{{orange}}on-chain tx failure:{{/}} %s %t\n", string(result.Error), result.Success) + } + } else { + // We can't error match here because we receive it over the wire. + if !strings.Contains(wsErr.Error(), ws.ErrExpired.Error()) { + utils.Outf("{{orange}}pre-execute tx failure:{{/}} %v\n", wsErr) + } + } + t.totalTxs++ + t.l.Unlock() +} + +func (t *tracker) logState(ctx context.Context, cli *jsonrpc.JSONRPCClient) { + // Log stats + tick := time.NewTicker(1 * time.Second) // ensure no duplicates created + var psent int64 + go func() { + defer tick.Stop() + for { + select { + case <-tick.C: + current := t.sent.Load() + t.l.Lock() + if t.totalTxs > 0 { + unitPrices, err := cli.UnitPrices(ctx, false) + if err != nil { + continue + } + utils.Outf( + "{{yellow}}txs seen:{{/}} %d {{yellow}}success rate:{{/}} %.2f%% {{yellow}}inflight:{{/}} %d {{yellow}}issued/s:{{/}} %d {{yellow}}unit prices:{{/}} [%s]\n", //nolint:lll + t.totalTxs, + float64(t.confirmedTxs)/float64(t.totalTxs)*100, + t.inflight.Load(), + current-psent, + unitPrices, + ) + } + t.l.Unlock() + psent = current + case <-ctx.Done(): + return + } + } + }() +} + +func (t *tracker) uniqueBytes() []byte { + return binary.BigEndian.AppendUint64(nil, uint64(t.sent.Add(1))) +} diff --git a/vm/registry.go b/vm/registry.go deleted file mode 100644 index 1565629d17..0000000000 --- a/vm/registry.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. -// See the file LICENSE for licensing terms. - -package vm - -import "github.com/ava-labs/hypersdk/chain" - -type Registry struct { - actionRegistry chain.ActionRegistry - authRegistry chain.AuthRegistry - outputRegistry chain.OutputRegistry -} - -func NewRegistry(action chain.ActionRegistry, auth chain.AuthRegistry, output chain.OutputRegistry) *Registry { - return &Registry{ - actionRegistry: action, - authRegistry: auth, - outputRegistry: output, - } -} - -func (r *Registry) ActionRegistry() chain.ActionRegistry { - return r.actionRegistry -} - -func (r *Registry) AuthRegistry() chain.AuthRegistry { - return r.authRegistry -} - -func (r *Registry) OutputRegistry() chain.OutputRegistry { - return r.outputRegistry -} diff --git a/vm/resolutions.go b/vm/resolutions.go index 6947aa02db..2f24b4c5db 100644 --- a/vm/resolutions.go +++ b/vm/resolutions.go @@ -18,6 +18,7 @@ import ( "go.uber.org/zap" "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/fees" "github.com/ava-labs/hypersdk/genesis" "github.com/ava-labs/hypersdk/internal/builder" @@ -62,6 +63,18 @@ func (vm *VM) Logger() logging.Logger { return vm.snowCtx.Log } +func (vm *VM) ActionCodec() *codec.TypeParser[chain.Action] { + return vm.Registry.ActionRegistry() +} + +func (vm *VM) AuthCodec() *codec.TypeParser[chain.Auth] { + return vm.Registry.AuthRegistry() +} + +func (vm *VM) OutputCodec() *codec.TypeParser[codec.Typed] { + return vm.Registry.OutputRegistry() +} + func (vm *VM) Rules(t int64) chain.Rules { return vm.ruleFactory.GetRules(t) } diff --git a/vm/vm.go b/vm/vm.go index 9cd8b614fd..61995c86f7 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -66,7 +66,7 @@ const ( ) type VM struct { - Registry + chain.Registry DataDir string v *version.Semantic @@ -162,10 +162,7 @@ func New( } return &VM{ - Registry: *NewRegistry( - registry.ActionRegistry(), - registry.AuthRegistry(), - registry.OutputRegistry()), + Registry: registry, v: v, stateManager: stateManager, config: NewConfig(), @@ -368,7 +365,7 @@ func (vm *VM) Initialize( snowCtx.Log.Info("genesis state created", zap.Stringer("root", root)) // Create genesis block - genesisBlk, err := chain.ParseStatelessBlock( + genesisBlk, err := chain.ParseStatefulBlock( ctx, chain.NewGenesisBlock(root), nil, @@ -922,13 +919,7 @@ func (vm *VM) Submit( // Verify auth if not already verified by caller if verifyAuth && vm.config.VerifyAuth { - unsignedTxBytes, err := tx.UnsignedBytes() - if err != nil { - // Should never fail - errs = append(errs, err) - continue - } - if err := tx.Auth.Verify(ctx, unsignedTxBytes); err != nil { + if err := tx.VerifyAuth(ctx); err != nil { // Failed signature verification is the only safe place to remove // a transaction in listeners. Every other case may still end up with // the transaction in a block.