diff --git a/.devcontainer/contracts-tutorial/devcontainer.json b/.devcontainer/contracts-tutorial/devcontainer.json index 0e16921802..798cebf2fd 100644 --- a/.devcontainer/contracts-tutorial/devcontainer.json +++ b/.devcontainer/contracts-tutorial/devcontainer.json @@ -5,7 +5,7 @@ "features": { "ghcr.io/devcontainers/features/go:1": { - "version": "1.21.12" + "version": "1.22.8" }, "ghcr.io/devcontainers/features/rust:1": {}, "ghcr.io/devcontainers/features/common-utils:2": { diff --git a/.github/actions/install-go/action.yml b/.github/actions/install-go/action.yml index 28b584f038..25ad754edc 100644 --- a/.github/actions/install-go/action.yml +++ b/.github/actions/install-go/action.yml @@ -17,6 +17,6 @@ runs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21.10' + go-version: '1.22.8' cache: ${{ inputs.cache }} cache-dependency-path: ${{ inputs.cache-dependency-path }} diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/contracts-ci.yml similarity index 77% rename from .github/workflows/rust-ci.yml rename to .github/workflows/contracts-ci.yml index ef1872b3c9..fb0d94dc44 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/contracts-ci.yml @@ -1,7 +1,7 @@ # Copyright (C) 2023, Ava Labs, Inc. All rights reserved. # See the file LICENSE for licensing terms. -name: Rust CI +name: Contracts CI on: push: @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: true jobs: - fmt: + cargo-fmt: runs-on: ubuntu-latest steps: - name: Checkout @@ -46,7 +46,7 @@ jobs: taplo fmt git diff --color=always - docs: + cargo-doc: runs-on: ubuntu-latest steps: - name: Checkout @@ -61,7 +61,7 @@ jobs: - name: Run doc tests run: RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features --document-private-items --all - clippy: + cargo-clippy: runs-on: ubuntu-latest steps: - name: Checkout @@ -76,7 +76,7 @@ jobs: shell: bash run: cargo clippy --all --all-features --tests --benches --examples -- -D warnings - unit-tests: + cargo-test: runs-on: ubuntu-latest steps: - name: Checkout @@ -116,3 +116,35 @@ jobs: - name: Run cross tests run: | cross -v test -p wasmlanche --target=wasm32-unknown-emscripten + + go-test: + runs-on: ubuntu-20.04-32 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: ./.github/actions/install-go + - name: Install Rust + uses: ./.github/actions/install-rust + with: + targets: wasm32-unknown-unknown + cache: false + - name: Run unit tests + shell: bash + run: go test -v -race ./x/contracts/... + + go-bench: + runs-on: ubuntu-20.04-32 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: ./.github/actions/install-go + - name: Install Rust + uses: ./.github/actions/install-rust + with: + targets: wasm32-unknown-unknown + cache: false + - name: Run unit tests + shell: bash + run: go test -v -benchmem -run=^$ -bench=. -benchtime=1x ./x/contracts/... diff --git a/.github/workflows/hypersdk-ci.yml b/.github/workflows/hypersdk-ci.yml index 77a9b61a2b..8a7446c27c 100644 --- a/.github/workflows/hypersdk-ci.yml +++ b/.github/workflows/hypersdk-ci.yml @@ -51,11 +51,6 @@ jobs: uses: actions/checkout@v4 - name: Set up Go uses: ./.github/actions/install-go - - name: Install Rust - uses: ./.github/actions/install-rust - with: - targets: wasm32-unknown-unknown - cache: false - name: Run unit tests shell: bash run: scripts/tests.unit.sh @@ -68,11 +63,6 @@ jobs: uses: actions/checkout@v4 - name: Set up Go uses: ./.github/actions/install-go - - name: Install Rust - uses: ./.github/actions/install-rust - with: - targets: wasm32-unknown-unknown - cache: false - name: Run unit tests shell: bash run: scripts/tests.benchmark.sh @@ -188,90 +178,3 @@ jobs: with: vm-name: morpheusvm github-token: ${{ secrets.GITHUB_TOKEN }} - - # VMWithContracts - vmwithcontracts-lint: - needs: [hypersdk-tests] - if: ${{ needs.hypersdk-tests.outputs.only_contracts_changed != 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Go - uses: ./.github/actions/install-go - with: - cache-dependency-path: | - go.sum - examples/vmwithcontracts/go.sum - - name: Run static analysis tests - working-directory: ./examples/vmwithcontracts - shell: bash - run: scripts/lint.sh - - name: Build vm, cli - working-directory: ./examples/vmwithcontracts - shell: bash - run: - scripts/build.sh - - # vmwithcontracts-unit-tests: - # needs: [ hypersdk-tests ] - # if: ${{ needs.hypersdk-tests.outputs.only_contracts_changed != 'true' }} - # runs-on: ubuntu-20.04-32 - # timeout-minutes: 10 - # steps: - # - name: Checkout - # uses: actions/checkout@v4 - # - name: Set up Go - # uses: ./.github/actions/install-go - # with: - # cache-dependency-path: | - # go.sum - # examples/vmwithcontracts/go.sum - # - name: Run unit tests - # working-directory: ./examples/vmwithcontracts - # shell: bash - # run: scripts/tests.unit.sh - # - name: Run integration tests - # working-directory: ./examples/vmwithcontracts - # shell: bash - # run: scripts/tests.integration.sh - # - # vmwithcontracts-e2e-tests: - # needs: [ vmwithcontracts-lint, vmwithcontracts-unit-tests ] - # runs-on: ubuntu-20.04-32 - # timeout-minutes: 25 - # steps: - # - name: Checkout - # uses: actions/checkout@v4 - # - name: Set up Go - # uses: ./.github/actions/install-go - # with: - # cache-dependency-path: | - # go.sum - # examples/vmwithcontracts/go.sum - # - name: Run e2e tests - # working-directory: ./examples/vmwithcontracts - # shell: bash - # run: scripts/run.sh - # env: - # MODE: 'test' - # - name: Upload tmpnet network dir - # uses: ava-labs/avalanchego/.github/actions/upload-tmpnet-artifact@v1-actions - # if: always() - # with: - # name: vmwithcontracts-e2e-tmpnet-data - # - # vmwithcontracts-release: - # needs: [ vmwithcontracts-e2e-tests ] - # # We build with 20.04 to maintain max compatibility: https://github.com/golang/go/issues/57328 - # runs-on: ubuntu-20.04-32 - # permissions: - # contents: write - # steps: - # - name: Checkout - # uses: actions/checkout@v4 - # - uses: ./.github/actions/vm-release - # with: - # vm-name: vmwithcontracts - # github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7670e2b36e..88ecfa2853 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thank you for your interest in contributing to `hypersdk`! By contributing to hy To contribute to `hypersdk`, you'll need: -- [Go](https://golang.org/dl/) 1.21 or higher +- [Go](https://golang.org/dl/) 1.22.8 or higher ### Setting up your development environment diff --git a/Cargo.toml b/Cargo.toml index abc45e93d7..b918ef1ee3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "x/contracts/test/contracts/*", "x/contracts/simulator", "x/contracts/examples/tutorial", + "x/contracts/examples/nft", "x/contracts/examples/multisig", ] resolver = "2" diff --git a/abi/abi.go b/abi/abi.go index d605aebacb..1004b9c812 100644 --- a/abi/abi.go +++ b/abi/abi.go @@ -200,3 +200,48 @@ func describeStruct(t reflect.Type) ([]Field, []reflect.Type, error) { return fields, otherStructsSeen, nil } + +func (a *ABI) FindOutputByID(id uint8) (TypedStruct, bool) { + for _, output := range a.Outputs { + if output.ID == id { + return output, true + } + } + return TypedStruct{}, false +} + +func (a *ABI) FindActionByID(id uint8) (TypedStruct, bool) { + for _, action := range a.Actions { + if action.ID == id { + return action, true + } + } + return TypedStruct{}, false +} + +func (a *ABI) FindOutputByName(name string) (TypedStruct, bool) { + for _, output := range a.Outputs { + if output.Name == name { + return output, true + } + } + return TypedStruct{}, false +} + +func (a *ABI) FindActionByName(name string) (TypedStruct, bool) { + for _, action := range a.Actions { + if action.Name == name { + return action, true + } + } + return TypedStruct{}, false +} + +func (a *ABI) FindTypeByName(name string) (Type, bool) { + for _, typ := range a.Types { + if typ.Name == name { + return typ, true + } + } + return Type{}, false +} diff --git a/abi/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..c0ecb2ef6a --- /dev/null +++ b/abi/dynamic/reflect_marshal.go @@ -0,0 +1,200 @@ +// 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 := inputABI.FindTypeByName(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) + } + + var typeID byte + found := false + for _, action := range inputABI.Actions { + if action.Name == typeName { + typeID = action.ID + found = true + break + } + } + if !found { + return nil, fmt.Errorf("action %s not found in ABI", typeName) + } + + writer := codec.NewWriter(1, consts.NetworkSizeLimit) + writer.PackByte(typeID) + if err := codec.LinearCodec.MarshalInto(value, writer.Packer); err != nil { + return nil, fmt.Errorf("failed to marshal struct: %w", err) + } + + return writer.Bytes(), nil +} + +func UnmarshalOutput(inputABI abi.ABI, data []byte) (string, error) { + if len(data) == 0 { + return "", nil + } + + typeID := data[0] + outputType, ok := inputABI.FindOutputByID(typeID) + if !ok { + return "", fmt.Errorf("output with id %d not found in ABI", typeID) + } + + return Unmarshal(inputABI, data[1:], outputType.Name) +} + +func UnmarshalAction(inputABI abi.ABI, data []byte) (string, error) { + if len(data) == 0 { + return "", nil + } + + typeID := data[0] + actionType, ok := inputABI.FindActionByID(typeID) + if !ok { + return "", fmt.Errorf("action with id %d not found in ABI", typeID) + } + + return Unmarshal(inputABI, data[1:], actionType.Name) +} + +func Unmarshal(inputABI abi.ABI, data []byte, typeName string) (string, error) { + typeCache := make(map[string]reflect.Type) + + typ, err := getReflectType(typeName, inputABI, typeCache) + 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 := inputABI.FindTypeByName(abiTypeName) + if !ok { + return nil, fmt.Errorf("type %s not found in ABI", abiTypeName) + } + + // It is a struct, as we don't support anything else as custom types + fields := make([]reflect.StructField, len(abiType.Fields)) + + for i, field := range abiType.Fields { + fieldType, err := getReflectType(field.Type, inputABI, typeCache) + if err != nil { + 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 + } +} diff --git a/abi/dynamic/reflect_marshal_test.go b/abi/dynamic/reflect_marshal_test.go new file mode 100644 index 0000000000..1b0c41a65e --- /dev/null +++ b/abi/dynamic/reflect_marshal_test.go @@ -0,0 +1,96 @@ +// 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 + expectedTypeID, found := abi.FindActionByName(tc.typeName) + require.True(found, "action %s not found in ABI", tc.typeName) + + expectedHex := hex.EncodeToString([]byte{expectedTypeID.ID}) + strings.TrimSpace(string(mustReadFile(t, "../testdata/"+tc.name+".hex"))) + require.Equal(expectedHex, hex.EncodeToString(objectBytes)) + + unmarshaledJSON, err := UnmarshalAction(abi, 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..95175d7cf3 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, @@ -41,4 +42,5 @@ type VM interface { GetVerifyAuth() bool ReadState(ctx context.Context, keys [][]byte) ([][]byte, []error) ImmutableState(ctx context.Context) (state.Immutable, error) + BalanceHandler() chain.BalanceHandler } diff --git a/api/indexer/indexer.go b/api/indexer/indexer.go index 6f70b8928e..809960115a 100644 --- a/api/indexer/indexer.go +++ b/api/indexer/indexer.go @@ -174,6 +174,7 @@ func (i *Indexer) storeTransactions(blk *chain.ExecutedBlock) error { result.Units, result.Fee, result.Outputs, + string(result.Error), ); err != nil { return err } @@ -190,6 +191,7 @@ func (*Indexer) storeTransaction( units fees.Dimensions, fee uint64, outputs [][]byte, + errorStr string, ) error { outputLength := consts.ByteLen // Single byte containing number of outputs for _, output := range outputs { @@ -206,19 +208,20 @@ func (*Indexer) storeTransaction( for _, output := range outputs { writer.PackBytes(output) } + writer.PackString(errorStr) if err := writer.Err(); err != nil { return err } return batch.Put(txID[:], writer.Bytes()) } -func (i *Indexer) GetTransaction(txID ids.ID) (bool, int64, bool, fees.Dimensions, uint64, [][]byte, error) { +func (i *Indexer) GetTransaction(txID ids.ID) (bool, int64, bool, fees.Dimensions, uint64, [][]byte, string, error) { v, err := i.txDB.Get(txID[:]) if errors.Is(err, database.ErrNotFound) { - return false, 0, false, fees.Dimensions{}, 0, nil, nil + return false, 0, false, fees.Dimensions{}, 0, nil, "", nil } if err != nil { - return false, 0, false, fees.Dimensions{}, 0, nil, err + return false, 0, false, fees.Dimensions{}, 0, nil, "", err } reader := codec.NewReader(v, consts.NetworkSizeLimit) timestamp := reader.UnpackUint64(true) @@ -231,14 +234,15 @@ func (i *Indexer) GetTransaction(txID ids.ID) (bool, int64, bool, fees.Dimension for i := range outputs { outputs[i] = reader.UnpackLimitedBytes(consts.NetworkSizeLimit) } + errorStr := reader.UnpackString(false) if err := reader.Err(); err != nil { - return false, 0, false, fees.Dimensions{}, 0, nil, err + return false, 0, false, fees.Dimensions{}, 0, nil, "", err } dimensions, err := fees.UnpackDimensions(dimensionsBytes) if err != nil { - return false, 0, false, fees.Dimensions{}, 0, nil, err + return false, 0, false, fees.Dimensions{}, 0, nil, "", err } - return true, int64(timestamp), success, dimensions, fee, outputs, nil + return true, int64(timestamp), success, dimensions, fee, outputs, errorStr, nil } func (i *Indexer) Close() error { diff --git a/api/indexer/server.go b/api/indexer/server.go index f555bdc6c9..e8cec42f03 100644 --- a/api/indexer/server.go +++ b/api/indexer/server.go @@ -111,6 +111,7 @@ type GetTxResponse struct { Units fees.Dimensions `json:"units"` Fee uint64 `json:"fee"` Outputs []codec.Bytes `json:"result"` + ErrorStr string `json:"errorStr"` } type Server struct { @@ -122,7 +123,7 @@ func (s *Server) GetTx(req *http.Request, args *GetTxRequest, reply *GetTxRespon _, span := s.tracer.Start(req.Context(), "Indexer.GetTx") defer span.End() - found, t, success, units, fee, outputs, err := s.indexer.GetTransaction(args.TxID) + found, t, success, units, fee, outputs, errorStr, err := s.indexer.GetTransaction(args.TxID) if err != nil { return err } @@ -139,5 +140,6 @@ func (s *Server) GetTx(req *http.Request, args *GetTxRequest, reply *GetTxRespon wrappedOutputs[i] = codec.Bytes(output) } reply.Outputs = wrappedOutputs + reply.ErrorStr = errorStr return nil } diff --git a/api/jsonrpc/client.go b/api/jsonrpc/client.go index 54e89d9f2e..d2253c2982 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,16 @@ 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, actionsBytes [][]byte) ([][]byte, error) { args := &ExecuteActionArgs{ - Actor: actor, - Action: actionBytes, + Actor: actor, + Actions: actionsBytes, } resp := new(ExecuteActionReply) - err = cli.requester.SendRequest( + err := cli.requester.SendRequest( ctx, - "executeAction", + "executeActions", args, resp, ) @@ -219,7 +214,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 +230,44 @@ 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 +} + +func (cli *JSONRPCClient) GetBalance(ctx context.Context, addr codec.Address) (uint64, error) { + args := &GetBalanceArgs{ + Address: addr, + } + resp := new(GetBalanceReply) + err := cli.requester.SendRequest( + ctx, + "getBalance", + args, + resp, + ) + return resp.Balance, err +} diff --git a/api/jsonrpc/server.go b/api/jsonrpc/server.go index 8515c03a9a..88b7bf94c7 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,170 @@ 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, chain.CreateActionID(ids.Empty, uint8(actionIndex))) + + // flatten the map to a slice of keys + storageKeysToRead := make([][]byte, 0, len(stateKeysWithPermissions)) + for key := range stateKeysWithPermissions { + storageKeysToRead = append(storageKeysToRead, []byte(key)) + } + + 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 + } + + 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 +} - 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) +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"` +} + +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) } - for i, value := range values { - if value == nil { - continue + if len(actions) == 0 { + return errSimulateZeroActions + } + currentState, err := j.vm.ImmutableState(ctx) + if err != nil { + return err + } + + 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 } - storage[string(storageKeysToRead[i])] = value + actionResult.StateKeys = recorder.GetStateKeys() + reply.ActionResults = append(reply.ActionResults, actionResult) + currentState = recorder } + return nil +} - ts := tstate.New(1) - tsv := ts.NewView(stateKeysWithPermissions, storage) - - output, err := action.Execute( - ctx, - j.vm.Rules(now), - tsv, - now, - args.Actor, - ids.Empty, - ) +type GetBalanceArgs struct { + Address codec.Address `json:"address"` +} + +type GetBalanceReply struct { + Balance uint64 `json:"balance"` +} + +func (j *JSONRPCServer) GetBalance( + req *http.Request, + args *GetBalanceArgs, + reply *GetBalanceReply, +) error { + ctx, span := j.vm.Tracer().Start(req.Context(), "JSONRPCServer.GetBalance") + defer span.End() + + im, err := j.vm.ImmutableState(ctx) if err != nil { - reply.Error = fmt.Sprintf("failed to execute action: %s", err) - return nil + return err } - encodedOutput, err := chain.MarshalTyped(output) + balance, err := j.vm.BalanceHandler().GetBalance(ctx, args.Address, im) if err != nil { - return fmt.Errorf("failed to marshal output: %w", err) + return err } - reply.Output = encodedOutput - + reply.Balance = balance return nil } diff --git a/api/ws/client.go b/api/ws/client.go index ed97aee342..86ae111720 100644 --- a/api/ws/client.go +++ b/api/ws/client.go @@ -165,10 +165,14 @@ func (c *WebSocketClient) ListenBlock( // IssueTx sends [tx] to the streaming rpc server. func (c *WebSocketClient) RegisterTx(tx *chain.Transaction) error { + return c.RegisterRawTx(tx.Bytes()) +} + +func (c *WebSocketClient) RegisterRawTx(txBytes []byte) error { if c.closed { return ErrClosed } - return c.mb.Send(append([]byte{TxMode}, tx.Bytes()...)) + return c.mb.Send(append([]byte{TxMode}, txBytes...)) } // ListenTx listens for responses from the streamingServer. diff --git a/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..d6e6f52b20 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 @@ -424,7 +424,7 @@ func (b *StatefulBlock) innerVerify(ctx context.Context, vctx VerifyContext) err } // Fetch parent height key and ensure block height is valid - heightKey := HeightKey(b.vm.StateManager().HeightKey()) + heightKey := HeightKey(b.vm.MetadataManager().HeightPrefix()) parentHeightRaw, err := parentView.GetValue(ctx, heightKey) if err != nil { return err @@ -441,7 +441,7 @@ func (b *StatefulBlock) innerVerify(ctx context.Context, vctx VerifyContext) err // // Parent may not be available (if we preformed state sync), so we // can't rely on being able to fetch it during verification. - timestampKey := TimestampKey(b.vm.StateManager().TimestampKey()) + timestampKey := TimestampKey(b.vm.MetadataManager().TimestampPrefix()) parentTimestampRaw, err := parentView.GetValue(ctx, timestampKey) if err != nil { return err @@ -484,7 +484,7 @@ func (b *StatefulBlock) innerVerify(ctx context.Context, vctx VerifyContext) err } // Compute next unit prices to use - feeKey := FeeKey(b.vm.StateManager().FeeKey()) + feeKey := FeeKey(b.vm.MetadataManager().FeePrefix()) feeRaw, err := parentView.GetValue(ctx, feeKey) if err != nil { return err @@ -737,7 +737,7 @@ func (b *StatefulBlock) View(ctx context.Context, verify bool) (state.View, erro if err != nil { return nil, err } - acceptedHeightRaw, err := acceptedState.Get(HeightKey(b.vm.StateManager().HeightKey())) + acceptedHeightRaw, err := acceptedState.Get(HeightKey(b.vm.MetadataManager().HeightPrefix())) if err != nil { return nil, err } diff --git a/chain/builder.go b/chain/builder.go index 906ca51a80..879e103ebc 100644 --- a/chain/builder.go +++ b/chain/builder.go @@ -92,7 +92,7 @@ func BuildBlock( } // Compute next unit prices to use - feeKey := FeeKey(vm.StateManager().FeeKey()) + feeKey := FeeKey(vm.MetadataManager().FeePrefix()) feeRaw, err := parentView.GetValue(ctx, feeKey) if err != nil { return nil, err @@ -128,7 +128,7 @@ func BuildBlock( txsAttempted = 0 results = []*Result{} - sm = vm.StateManager() + bh = vm.BalanceHandler() // prepareStreamLock ensures we don't overwrite stream prefetching spawned // asynchronously. @@ -173,7 +173,7 @@ func BuildBlock( continue } - stateKeys, err := tx.StateKeys(sm) + stateKeys, err := tx.StateKeys(bh) if err != nil { // Drop bad transaction and continue // @@ -265,7 +265,7 @@ func BuildBlock( // Execute block tsv := ts.NewView(stateKeys, storage) - if err := tx.PreExecute(ctx, feeManager, sm, r, tsv, nextTime); err != nil { + if err := tx.PreExecute(ctx, feeManager, bh, r, tsv, nextTime); err != nil { // We don't need to rollback [tsv] here because it will never // be committed. if HandlePreExecute(log, err) { @@ -276,7 +276,7 @@ func BuildBlock( result, err := tx.Execute( ctx, feeManager, - sm, + bh, r, tsv, nextTime, @@ -372,9 +372,9 @@ func BuildBlock( } // Update chain metadata - heightKey := HeightKey(sm.HeightKey()) + heightKey := HeightKey(b.vm.MetadataManager().HeightPrefix()) heightKeyStr := string(heightKey) - timestampKey := TimestampKey(b.vm.StateManager().TimestampKey()) + timestampKey := TimestampKey(b.vm.MetadataManager().TimestampPrefix()) timestampKeyStr := string(timestampKey) feeKeyStr := string(feeKey) 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 3884670a4b..22839866e1 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 { @@ -71,7 +65,8 @@ type VM interface { GetStatefulBlock(context.Context, ids.ID) (*StatefulBlock, error) State() (merkledb.MerkleDB, error) - StateManager() StateManager + BalanceHandler() BalanceHandler + MetadataManager() MetadataManager Mempool() Mempool IsRepeat(context.Context, []*Transaction, set.Bits, bool) set.Bits @@ -155,9 +150,9 @@ type Rules interface { } type MetadataManager interface { - HeightKey() []byte - TimestampKey() []byte - FeeKey() []byte + HeightPrefix() []byte + TimestampPrefix() []byte + FeePrefix() []byte } type BalanceHandler interface { @@ -177,17 +172,10 @@ type BalanceHandler interface { // AddBalance adds [amount] to [addr]. AddBalance(ctx context.Context, addr codec.Address, mu state.Mutable, amount uint64, createAccount bool) error -} -// StateManager allows [Chain] to safely store certain types of items in state -// in a structured manner. If we did not use [StateManager], we may overwrite -// state written by actions or auth. -// -// None of these keys should be suffixed with the max amount of chunks they will -// use. This will be handled by the hypersdk. -type StateManager interface { - BalanceHandler - MetadataManager + // GetBalance returns the balance of [addr]. + // If [addr] does not exist, this should return 0 and no error. + GetBalance(ctx context.Context, addr codec.Address, im state.Immutable) (uint64, error) } type Object interface { @@ -223,8 +211,11 @@ type Action interface { // All keys specified must be suffixed with the number of chunks that could ever be read from that // key (formatted as a big-endian uint16). This is used to automatically calculate storage usage. // - // If any key is removed and then re-created, this will count as a creation instead of a modification. - StateKeys(actor codec.Address) state.Keys + // If any key is removed and then re-created, this will count as a creation + // instead of a modification. + // + // [actionID] is a unique, but nonrandom identifier for each [Action]. + StateKeys(actor codec.Address, actionID ids.ID) state.Keys // Execute actually runs the [Action]. Any state changes that the [Action] performs should // be done here. @@ -232,7 +223,10 @@ type Action interface { // If any keys are touched during [Execute] that are not specified in [StateKeys], the transaction // will revert and the max fee will be charged. // - // If [Execute] returns an error, execution will halt and any state changes will revert. + // If [Execute] returns an error, execution will halt and any state changes + // will revert. + // + // [actionID] is a unique, but nonrandom identifier for each [Action]. Execute( ctx context.Context, r Rules, diff --git a/chain/processor.go b/chain/processor.go index dab03af4de..b6052f14db 100644 --- a/chain/processor.go +++ b/chain/processor.go @@ -34,7 +34,7 @@ func (b *StatefulBlock) Execute( defer span.End() var ( - sm = b.vm.StateManager() + bh = b.vm.BalanceHandler() numTxs = len(b.Txs) t = b.GetTimestamp() @@ -49,7 +49,7 @@ func (b *StatefulBlock) Execute( i := li tx := ltx - stateKeys, err := tx.StateKeys(sm) + stateKeys, err := tx.StateKeys(bh) if err != nil { f.Stop() e.Stop() @@ -57,7 +57,7 @@ func (b *StatefulBlock) Execute( } // Ensure we don't consume too many units - units, err := tx.Units(sm, r) + units, err := tx.Units(bh, r) if err != nil { f.Stop() e.Stop() @@ -88,11 +88,11 @@ func (b *StatefulBlock) Execute( tsv := ts.NewView(stateKeys, storage) // Ensure we have enough funds to pay fees - if err := tx.PreExecute(ctx, feeManager, sm, r, tsv, t); err != nil { + if err := tx.PreExecute(ctx, feeManager, bh, r, tsv, t); err != nil { return err } - result, err := tx.Execute(ctx, feeManager, sm, r, tsv, t) + result, err := tx.Execute(ctx, feeManager, bh, r, tsv, t) if err != nil { return err } diff --git a/chain/transaction.go b/chain/transaction.go index b609db6a2a..4903b7b9b4 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,79 @@ 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() +func SignRawActionBytesTx( + base *Base, + rawActionsBytes []byte, + authFactory AuthFactory, +) ([]byte, error) { + p := codec.NewWriter(base.Size(), consts.NetworkSizeLimit) + base.Marshal(p) + p.PackFixedBytes(rawActionsBytes) + + auth, err := authFactory.Sign(p.Bytes()) if err != nil { - // Should never occur because populated during unmarshal - return err + return nil, err } - return t.Auth.Verify(ctx, msg) + p.PackByte(auth.GetTypeID()) + auth.Marshal(p) + return p.Bytes(), p.Err() +} + +func (t *TransactionData) Expiry() int64 { return t.Base.Timestamp } + +func (t *TransactionData) MaxFee() uint64 { return t.Base.MaxFee } + +func (t *TransactionData) Marshal(p *codec.Packer) error { + if len(t.unsignedBytes) > 0 { + p.PackFixedBytes(t.unsignedBytes) + return p.Err() + } + 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,25 +180,21 @@ 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) { +func (t *Transaction) StateKeys(bh BalanceHandler) (state.Keys, error) { if t.stateKeys != nil { return t.stateKeys, nil } stateKeys := make(state.Keys) // Verify the formatting of state keys passed by the controller - for _, action := range t.Actions { - for k, v := range action.StateKeys(t.Auth.Actor()) { + for i, action := range t.Actions { + for k, v := range action.StateKeys(t.Auth.Actor(), CreateActionID(t.ID(), uint8(i))) { if !stateKeys.Add(k, v) { return nil, ErrInvalidKeyValue } } } - for k, v := range sm.SponsorStateKeys(t.Auth.Sponsor()) { + for k, v := range bh.SponsorStateKeys(t.Auth.Sponsor()) { if !stateKeys.Add(k, v) { return nil, ErrInvalidKeyValue } @@ -150,11 +205,8 @@ 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) { +func (t *Transaction) Units(bh BalanceHandler, r Rules) (fees.Dimensions, error) { // Calculate compute usage computeOp := math.NewUint64Operator(r.GetBaseComputeUnits()) for _, action := range t.Actions { @@ -167,7 +219,7 @@ func (t *Transaction) Units(sm StateManager, r Rules) (fees.Dimensions, error) { } // Calculate storage usage - stateKeys, err := t.StateKeys(sm) + stateKeys, err := t.StateKeys(bh) if err != nil { return fees.Dimensions{}, err } @@ -204,81 +256,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, + bh BalanceHandler, r Rules, im state.Immutable, timestamp int64, @@ -305,7 +286,7 @@ func (t *Transaction) PreExecute( if end >= 0 && timestamp > end { return ErrAuthNotActivated } - units, err := t.Units(s, r) + units, err := t.Units(bh, r) if err != nil { return err } @@ -313,7 +294,7 @@ func (t *Transaction) PreExecute( if err != nil { return err } - return s.CanDeduct(ctx, t.Auth.Sponsor(), im, fee) + return bh.CanDeduct(ctx, t.Auth.Sponsor(), im, fee) } // Execute after knowing a transaction can pay a fee. Attempt @@ -323,13 +304,13 @@ func (t *Transaction) PreExecute( func (t *Transaction) Execute( ctx context.Context, feeManager *internalfees.Manager, - s StateManager, + bh BalanceHandler, r Rules, ts *tstate.TStateView, timestamp int64, ) (*Result, error) { // Always charge fee first - units, err := t.Units(s, r) + units, err := t.Units(bh, r) if err != nil { // Should never happen return nil, err @@ -339,7 +320,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 := bh.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 +366,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 +474,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 +492,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 i, 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, CreateActionID(ids.Empty, uint8(i))) + 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..999c906110 100644 --- a/chain/transaction_test.go +++ b/chain/transaction_test.go @@ -13,6 +13,7 @@ import ( "github.com/ava-labs/hypersdk/auth" "github.com/ava-labs/hypersdk/chain" "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/consts" "github.com/ava-labs/hypersdk/crypto/ed25519" "github.com/ava-labs/hypersdk/state" ) @@ -32,7 +33,7 @@ func (*abstractMockAction) Execute(_ context.Context, _ chain.Rules, _ state.Mut panic("unimplemented") } -func (*abstractMockAction) StateKeys(_ codec.Address) state.Keys { +func (*abstractMockAction) StateKeys(_ codec.Address, _ ids.ID) state.Keys { panic("unimplemented") } @@ -77,7 +78,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 +106,43 @@ 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{ + // call UnsignedBytes so that the "unsignedBytes" field would get populated. + txBeforeSignBytes, err := tx.UnsignedBytes() + require.NoError(err) + + signedTx, err := tx.Sign(factory, actionCodec, authCodec) + require.NoError(err) + unsignedTxAfterSignBytes, err := signedTx.TransactionData.UnsignedBytes() + require.NoError(err) + require.Equal(txBeforeSignBytes, unsignedTxAfterSignBytes) + require.NotNil(signedTx.Auth) + require.Equal(len(signedTx.Actions), len(tx.Actions)) + for i, action := range signedTx.Actions { + require.Equal(tx.Actions[i], action) + } + + unsignedTxBytes, err := signedTx.UnsignedBytes() + require.NoError(err) + originalUnsignedTxBytes, err := tx.UnsignedBytes() + require.NoError(err) + + require.Equal(unsignedTxBytes, originalUnsignedTxBytes) + require.Len(unsignedTxBytes, 168) +} + +func TestSignRawActionBytesTx(t *testing.T) { + require := require.New(t) + tx := chain.TransactionData{ Base: &chain.Base{ Timestamp: 1724315246000, ChainID: [32]byte{1, 2, 3, 4, 5, 6, 7}, @@ -139,21 +166,27 @@ func TestMarshalUnmarshal(t *testing.T) { }, } - require.Nil(tx.Auth) - signedTx, err := tx.Sign(factory, actionRegistry, authRegistry) + priv, err := ed25519.GeneratePrivateKey() require.NoError(err) - require.Equal(txBeforeSign, tx) - require.NotNil(signedTx.Auth) - require.Equal(len(signedTx.Actions), len(tx.Actions)) - for i, action := range signedTx.Actions { - require.Equal(tx.Actions[i], action) - } + factory := auth.NewED25519Factory(priv) - unsignedTxBytes, err := signedTx.UnsignedBytes() + actionCodec := codec.NewTypeParser[chain.Action]() + authCodec := codec.NewTypeParser[chain.Auth]() + + err = authCodec.Register(&auth.ED25519{}, auth.UnmarshalED25519) require.NoError(err) - originalUnsignedTxBytes, err := tx.UnsignedBytes() + err = actionCodec.Register(&mockTransferAction{}, unmarshalTransfer) + require.NoError(err) + err = actionCodec.Register(&action2{}, unmarshalAction2) require.NoError(err) - require.Equal(unsignedTxBytes, originalUnsignedTxBytes) - require.Len(unsignedTxBytes, 168) + signedTx, err := tx.Sign(factory, actionCodec, authCodec) + require.NoError(err) + + p := codec.NewWriter(0, consts.NetworkSizeLimit) + require.NoError(signedTx.Actions.MarshalInto(p)) + actionsBytes := p.Bytes() + rawSignedTxBytes, err := chain.SignRawActionBytesTx(tx.Base, actionsBytes, factory) + require.NoError(err) + require.Equal(signedTx.Bytes(), rawSignedTxBytes) } diff --git a/cli/prompt/prompt.go b/cli/prompt/prompt.go index a5febf57b5..3b3f556e41 100644 --- a/cli/prompt/prompt.go +++ b/cli/prompt/prompt.go @@ -4,9 +4,9 @@ package prompt import ( - "encoding/hex" "errors" "fmt" + "math" "strconv" "strings" @@ -34,10 +34,7 @@ func Bytes(label string) ([]byte, error) { promptText := promptui.Prompt{ Label: label, Validate: func(input string) error { - if len(input) == 0 { - return ErrInputEmpty - } - _, err := hex.DecodeString(input) + _, err := codec.LoadHex(input, -1) return err }, } @@ -45,17 +42,15 @@ func Bytes(label string) ([]byte, error) { if err != nil { return nil, err } - return hex.DecodeString(hexString) + return codec.LoadHex(hexString, -1) } func Address(label string) (codec.Address, error) { promptText := promptui.Prompt{ Label: label, Validate: func(input string) error { - if len(input) == 0 { - return ErrInputEmpty - } - return nil + _, err := codec.StringToAddress(strings.TrimSpace(input)) + return err }, } recipient, err := promptText.Run() @@ -158,31 +153,74 @@ func Int( label string, max int, ) (int, error) { + stringToInt := func(input string, max int) (int, error) { + input = strings.TrimSpace(input) + + if len(input) == 0 { + return 0, ErrInputEmpty + } + amount, err := strconv.Atoi(input) + if err != nil { + return 0, err + } + if amount <= 0 { + return 0, fmt.Errorf("%d must be > 0", amount) + } + if amount > max { + return 0, fmt.Errorf("%d must be <= %d", amount, max) + } + return amount, nil + } + promptText := promptui.Prompt{ Label: label, Validate: func(input string) error { - if len(input) == 0 { - return ErrInputEmpty - } - amount, err := strconv.Atoi(input) - if err != nil { - return err - } - if amount <= 0 { - return fmt.Errorf("%d must be > 0", amount) - } - if amount > max { - return fmt.Errorf("%d must be <= %d", amount, max) - } - return nil + _, err := stringToInt(input, max) + return err }, } rawAmount, err := promptText.Run() if err != nil { return 0, err } - rawAmount = strings.TrimSpace(rawAmount) - return strconv.Atoi(rawAmount) + return stringToInt(rawAmount, max) +} + +func Uint( + label string, + max uint, +) (uint, error) { + stringToUint := func(input string, max uint) (uint, error) { + input = strings.TrimSpace(input) + + if len(input) == 0 { + return 0, ErrInputEmpty + } + amount, err := strconv.ParseUint(input, 10, 0) + if err != nil { + return 0, err + } + if amount > math.MaxUint { + return 0, fmt.Errorf("%d exceeds the maximum value for uint", amount) + } + if uint(amount) > max { + return 0, fmt.Errorf("%d must be <= %d", amount, max) + } + return uint(amount), nil + } + + promptText := promptui.Prompt{ + Label: label, + Validate: func(input string) error { + _, err := stringToUint(input, max) + return err + }, + } + rawAmount, err := promptText.Run() + if err != nil { + return 0, err + } + return stringToUint(rawAmount, max) } func Float( diff --git a/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/cmd/hypersdk-cli/README.md b/cmd/hypersdk-cli/README.md new file mode 100644 index 0000000000..f08df27b7e --- /dev/null +++ b/cmd/hypersdk-cli/README.md @@ -0,0 +1,151 @@ +# HyperSDK CLI + +A command-line interface for interacting with HyperSDK-based chains. + +## Installation + +```bash +go install github.com/ava-labs/hypersdk/cmd/hypersdk-cli@4510f51720d2e0fdecfd7fa08350e7c3eab3cf53 +``` + +FIXME: Has to point to the commit with the latest update from main, or just `@main` later on. + +## Configuration + +The CLI stores configuration in `~/.hypersdk-cli/config.yaml`. This includes: +- Private key +- Endpoint URL + +Example setup for a local HyperSDK VM: +```bash +hypersdk-cli endpoint set --endpoint=http://localhost:9650/ext/bc/morpheusvm/ +hypersdk-cli key set --key=0x323b1d8f4eed5f0da9da93071b034f2dce9d2d22692c172f3cb252a64ddfafd01b057de320297c29ad0c1f589ea216869cf1938d88c9fbd70d6748323dbf2fa7 +``` + +## Global Flags + +- `--endpoint`: Override the default endpoint for a single command +- `-o, --output`: Set output format (`text` or `json`) + +## Commands + + +### key + +Manage keys. + +#### generate + +Generate a new ED25519 key pair. + +```bash +hypersdk-cli key generate +``` + +#### set + +Set the private ED25519 key. + +```bash +hypersdk-cli key set --key=0x323b1d8f4eed5f0da9da93071b034f2dce9d2d22692c172f3cb252a64ddfafd01b057de320297c29ad0c1f589ea216869cf1938d88c9fbd70d6748323dbf2fa7 +``` + +`--key` could also be file path like `examples/morpheusvm/demo.pk` + +### endpoint + +Print the current endpoint URL. + +```bash +hypersdk-cli endpoint +``` + +#### set + +Set the endpoint URL. + +```bash +hypersdk-cli endpoint set --endpoint=http://localhost:9650/ext/bc/morpheusvm/ +``` + +### ping + +Check connectivity with the current endpoint. + +```bash +hypersdk-cli ping +``` + +### address + +Print the current key address. + +```bash +hypersdk-cli address +``` + +### actions + +Print the list of actions available in the ABI. + +```bash +hypersdk-cli actions +``` + +For JSON output: + +```bash +hypersdk-cli actions -o json +``` + +### read + +Simulate a single action transaction. + +```bash +hypersdk-cli read Transfer --data to=0x000000000000000000000000000000000000000000000000000000000000000000,value=12 +``` + +For interactive input remove --data from the comand line: + +```bash +hypersdk-cli read Transfer +``` + +### tx + +Send a transaction with a single action. + +```bash +hypersdk-cli tx Transfer --data to=0x000000000000000000000000000000000000000000000000000000000000000000,value=12,memo=0x001234 +``` + +For interactive input: + +```bash +hypersdk-cli tx Transfer +``` + +### balance + +Query the balance of an address + +```bash +hypersdk-cli balance --sender 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 +``` + +If `--sender` isn't provided, the address associated with the private key in +`~/.hypersdk-cli/config.yaml` is queried. + + +## Notes + +- Only flat actions are supported. Arrays, slices, embedded structs, maps, and struct fields are not supported. +- The CLI supports ED25519 keys only. +- If `--data` is supplied or JSON output is selected, the CLI will not ask for action arguments interactively. + +## Known Issues + +- The `maxFee` for transactions is currently hardcoded to 1,000,000. +- The `key set` and `endpoint set` commands use a nested command structure which adds unnecessary complexity for a small CLI tool. A flatter command structure would be more appropriate. +- Currency values are represented as uint64 without decimal point support in the ABI. The CLI cannot automatically parse decimal inputs (e.g. "12.0") since there is no currency type annotation. Users must enter the raw uint64 value including all decimal places (e.g. "12000000000" for 12 coins with 9 decimal places). diff --git a/cmd/hypersdk-cli/actions.go b/cmd/hypersdk-cli/actions.go new file mode 100644 index 0000000000..d82d08fbae --- /dev/null +++ b/cmd/hypersdk-cli/actions.go @@ -0,0 +1,75 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/abi" + "github.com/ava-labs/hypersdk/api/jsonrpc" +) + +var actionsCmd = &cobra.Command{ + Use: "actions", + Short: "Print the list of actions available in the ABI", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + client := jsonrpc.NewJSONRPCClient(endpoint) + + abi, err := client.GetABI(context.Background()) + if err != nil { + return fmt.Errorf("failed to get ABI: %w", err) + } + + return printValue(cmd, abiWrapper{ABI: abi}) + }, +} + +type abiWrapper struct { + ABI abi.ABI +} + +func (a abiWrapper) String() string { + result := "" + for _, action := range a.ABI.Actions { + result += fmt.Sprintf("---\n%s\n\n", action.Name) + typ, found := a.ABI.FindTypeByName(action.Name) + if !found { + result += fmt.Sprintf(" Error: Type not found for action %s\n", action.Name) + continue + } else { + result += "Inputs:\n" + for _, field := range typ.Fields { + result += fmt.Sprintf(" %s: %s\n", field.Name, field.Type) + } + } + + output, found := a.ABI.FindOutputByID(action.ID) + if !found { + result += fmt.Sprintf("No outputs for %s with id %d\n", action.Name, action.ID) + continue + } + + typ, found = a.ABI.FindTypeByName(output.Name) + if !found { + result += fmt.Sprintf(" Error: Type not found for output %s\n", output.Name) + continue + } + result += "\nOutputs:\n" + for _, field := range typ.Fields { + result += fmt.Sprintf(" %s: %s\n", field.Name, field.Type) + } + } + return result +} + +func init() { + rootCmd.AddCommand(actionsCmd) +} diff --git a/cmd/hypersdk-cli/address.go b/cmd/hypersdk-cli/address.go new file mode 100644 index 0000000000..e3651186a8 --- /dev/null +++ b/cmd/hypersdk-cli/address.go @@ -0,0 +1,50 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/auth" +) + +var addressCmd = &cobra.Command{ + Use: "address", + Short: "Print current key address", + RunE: func(cmd *cobra.Command, _ []string) error { + keyString, err := getConfigValue(cmd, "key", true) + if err != nil { + return fmt.Errorf("failed to get key: %w", err) + } + + key, err := privateKeyFromString(keyString) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + + addr := auth.NewED25519Address(key.PublicKey()) + addrString, err := addr.MarshalText() + if err != nil { + return fmt.Errorf("failed to marshal address: %w", err) + } + + return printValue(cmd, keyAddressCmdResponse{ + Address: string(addrString), + }) + }, +} + +type keyAddressCmdResponse struct { + Address string `json:"address"` +} + +func (r keyAddressCmdResponse) String() string { + return r.Address +} + +func init() { + rootCmd.AddCommand(addressCmd) +} diff --git a/cmd/hypersdk-cli/balance.go b/cmd/hypersdk-cli/balance.go new file mode 100644 index 0000000000..6f4940197a --- /dev/null +++ b/cmd/hypersdk-cli/balance.go @@ -0,0 +1,88 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/codec" +) + +var balanceCmd = &cobra.Command{ + Use: "balance [address]", + Short: "Get the balance of an address", + RunE: func(cmd *cobra.Command, _ []string) error { + // 1. figure out sender address + addressStr, err := cmd.Flags().GetString("sender") + if err != nil { + return fmt.Errorf("failed to get sender: %w", err) + } + + var address codec.Address + + if addressStr != "" { + address, err = codec.StringToAddress(addressStr) + if err != nil { + return fmt.Errorf("failed to convert sender to address: %w", err) + } + } else { + // ok, infer user's address from the private key + keyString, err := getConfigValue(cmd, "key", true) + if err != nil { + return fmt.Errorf("failed to get key from config: %w", err) + } + key, err := privateKeyFromString(keyString) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + address = auth.NewED25519Address(key.PublicKey()) + } + + // 2. create client + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + client := jsonrpc.NewJSONRPCClient(endpoint) + + // 3. get balance + balance, err := client.GetBalance(context.Background(), address) + errorString := "" + if err != nil { + errorString = err.Error() + } + + return printValue(cmd, balanceResponse{ + Balance: balance, + BalanceError: errorString, + }) + }, +} + +type balanceResponse struct { + Balance uint64 `json:"balance"` + BalanceError string `json:"error"` +} + +func (b balanceResponse) String() string { + var result strings.Builder + if b.BalanceError != "" { + result.WriteString(fmt.Sprintf("❌ Error: %s\n", b.BalanceError)) + } else { + result.WriteString(fmt.Sprintf("✅ Balance: %d\n", b.Balance)) + } + + return result.String() +} + +func init() { + balanceCmd.Flags().String("sender", "", "Address being queried in hex") + rootCmd.AddCommand(balanceCmd) +} diff --git a/cmd/hypersdk-cli/config.go b/cmd/hypersdk-cli/config.go new file mode 100644 index 0000000000..cfb51bb61c --- /dev/null +++ b/cmd/hypersdk-cli/config.go @@ -0,0 +1,116 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/ava-labs/hypersdk/codec" +) + +func init() { + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Fprintln(os.Stderr, "Error getting home directory:", err) + os.Exit(1) + } + + configDir := filepath.Join(homeDir, ".hypersdk-cli") + if err := os.MkdirAll(configDir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, "Error creating config directory:", err) + os.Exit(1) + } + + configFile := filepath.Join(configDir, "config.yaml") + if _, err := os.Stat(configFile); os.IsNotExist(err) { + if _, err := os.Create(configFile); err != nil { + fmt.Fprintln(os.Stderr, "Error creating config file:", err) + os.Exit(1) + } + } + + // Set config name and paths + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(configDir) + + // Read config + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + fmt.Fprintln(os.Stderr, "Error reading config:", err) + os.Exit(1) + } + // Config file not found; will be created when needed + } +} + +func isJSONOutputRequested(cmd *cobra.Command) (bool, error) { + output, err := getConfigValue(cmd, "output", false) + if err != nil { + return false, fmt.Errorf("failed to get output format: %w", err) + } + return strings.ToLower(output) == "json", nil +} + +func printValue(cmd *cobra.Command, v fmt.Stringer) error { + isJSON, err := isJSONOutputRequested(cmd) + if err != nil { + return err + } + + if isJSON { + jsonBytes, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } else { + fmt.Println(v.String()) + return nil + } +} + +func getConfigValue(cmd *cobra.Command, key string, required bool) (string, error) { + // Check flags first + if value, err := cmd.Flags().GetString(key); err == nil && value != "" { + return value, nil + } + + // Then check viper + if value := viper.GetString(key); value != "" { + return value, nil + } + + if required { + return "", fmt.Errorf("required value for %s not found", key) + } + + return "", nil +} + +func setConfigValue(key, value string) error { + viper.Set(key, value) + return viper.WriteConfig() +} + +func decodeFileOrHex(fileNameOrHex string) ([]byte, error) { + if decoded, err := codec.LoadHex(fileNameOrHex, -1); err == nil { + return decoded, nil + } + + if fileContents, err := os.ReadFile(fileNameOrHex); err == nil { + return fileContents, nil + } + + return nil, errors.New("unable to decode input as hex, or read as file path") +} diff --git a/cmd/hypersdk-cli/endpoint.go b/cmd/hypersdk-cli/endpoint.go new file mode 100644 index 0000000000..745bdf5100 --- /dev/null +++ b/cmd/hypersdk-cli/endpoint.go @@ -0,0 +1,36 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var endpointCmd = &cobra.Command{ + Use: "endpoint", + Short: "Manage endpoint", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + return printValue(cmd, endpointCmdResponse{ + Endpoint: endpoint, + }) + }, +} + +type endpointCmdResponse struct { + Endpoint string `json:"endpoint"` +} + +func (r endpointCmdResponse) String() string { + return r.Endpoint +} + +func init() { + rootCmd.AddCommand(endpointCmd) +} diff --git a/cmd/hypersdk-cli/endpoint_set.go b/cmd/hypersdk-cli/endpoint_set.go new file mode 100644 index 0000000000..e84dffa739 --- /dev/null +++ b/cmd/hypersdk-cli/endpoint_set.go @@ -0,0 +1,53 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "errors" + "fmt" + "log" + + "github.com/spf13/cobra" +) + +var endpointSetCmd = &cobra.Command{ + Use: "set", + Short: "Set the endpoint URL", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, err := cmd.Flags().GetString("endpoint") + if err != nil { + return fmt.Errorf("failed to get endpoint flag: %w", err) + } + + if endpoint == "" { + return errors.New("endpoint is required") + } + + if err := setConfigValue("endpoint", endpoint); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + return printValue(cmd, endpointSetCmdResponse{ + Endpoint: endpoint, + }) + }, +} + +type endpointSetCmdResponse struct { + Endpoint string `json:"endpoint"` +} + +func (r endpointSetCmdResponse) String() string { + return "Endpoint set to: " + r.Endpoint +} + +func init() { + endpointCmd.AddCommand(endpointSetCmd) + endpointSetCmd.Flags().String("endpoint", "", "Endpoint URL to set") + + err := endpointSetCmd.MarkFlagRequired("endpoint") + if err != nil { + log.Fatalf("failed to mark endpoint flag as required: %s", err) + } +} diff --git a/cmd/hypersdk-cli/key.go b/cmd/hypersdk-cli/key.go new file mode 100644 index 0000000000..d2f48c5064 --- /dev/null +++ b/cmd/hypersdk-cli/key.go @@ -0,0 +1,17 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "github.com/spf13/cobra" +) + +var keyCmd = &cobra.Command{ + Use: "key", + Short: "Manage keys", +} + +func init() { + rootCmd.AddCommand(keyCmd) +} diff --git a/cmd/hypersdk-cli/key_set.go b/cmd/hypersdk-cli/key_set.go new file mode 100644 index 0000000000..22eef68779 --- /dev/null +++ b/cmd/hypersdk-cli/key_set.go @@ -0,0 +1,97 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "encoding/hex" + "errors" + "fmt" + "log" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/crypto/ed25519" +) + +var keyGenerateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate a new key", + RunE: func(cmd *cobra.Command, _ []string) error { + newKey, err := ed25519.GeneratePrivateKey() + if err != nil { + return fmt.Errorf("failed to generate key: %w", err) + } + + return checkAndSavePrivateKey(cmd, hex.EncodeToString(newKey[:])) + }, +} + +var keySetCmd = &cobra.Command{ + Use: "set", + Short: "Set the private ED25519 key", + RunE: func(cmd *cobra.Command, _ []string) error { + keyString, err := cmd.Flags().GetString("key") + if err != nil { + return fmt.Errorf("failed to get key flag: %w", err) + } + if keyString == "" { + return errors.New("--key is required") + } + + return checkAndSavePrivateKey(cmd, keyString) + }, +} + +func checkAndSavePrivateKey(cmd *cobra.Command, keyStr string) error { + key, err := privateKeyFromString(keyStr) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + + // Use Viper to save the key + if err := setConfigValue("key", hex.EncodeToString(key[:])); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + addr := auth.NewED25519Address(key.PublicKey()) + + addrString, err := addr.MarshalText() + if err != nil { + return fmt.Errorf("failed to marshal address: %w", err) + } + + return printValue(cmd, keySetCmdResponse{ + Address: string(addrString), + }) +} + +func privateKeyFromString(keyStr string) (ed25519.PrivateKey, error) { + keyBytes, err := decodeFileOrHex(keyStr) + if err != nil { + return ed25519.EmptyPrivateKey, fmt.Errorf("failed to decode key: %w", err) + } + if len(keyBytes) != ed25519.PrivateKeyLen { + return ed25519.EmptyPrivateKey, fmt.Errorf("invalid private key length: %d", len(keyBytes)) + } + return ed25519.PrivateKey(keyBytes), nil +} + +type keySetCmdResponse struct { + Address string `json:"address"` +} + +func (r keySetCmdResponse) String() string { + return "✅ Key added successfully!\nAddress: " + r.Address +} + +func init() { + keyCmd.AddCommand(keySetCmd, keyGenerateCmd) + keySetCmd.Flags().String("key", "", "Private key in hex format or path to file containing the key") + + err := keySetCmd.MarkFlagRequired("key") + if err != nil { + log.Fatalf("failed to mark key flag as required: %s", err) + } +} diff --git a/cmd/hypersdk-cli/main.go b/cmd/hypersdk-cli/main.go new file mode 100644 index 0000000000..185c8c19df --- /dev/null +++ b/cmd/hypersdk-cli/main.go @@ -0,0 +1,35 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "hypersdk-cli", + Short: "HyperSDK CLI for interacting with HyperSDK-based chains", + Long: `A CLI application for performing read and write actions on HyperSDK-based chains.`, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} + +func init() { + rootCmd.PersistentFlags().StringP("output", "o", "text", "Output format (text or json)") + rootCmd.PersistentFlags().String("endpoint", "", "Override the default endpoint") + rootCmd.PersistentFlags().String("key", "", "Private ED25519 key as hex string") +} + +func main() { + Execute() +} diff --git a/cmd/hypersdk-cli/ping.go b/cmd/hypersdk-cli/ping.go new file mode 100644 index 0000000000..a4a66e071e --- /dev/null +++ b/cmd/hypersdk-cli/ping.go @@ -0,0 +1,51 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/api/jsonrpc" +) + +var endpointPingCmd = &cobra.Command{ + Use: "ping", + Short: "Ping the endpoint", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + client := jsonrpc.NewJSONRPCClient(endpoint) + + success, err := client.Ping(context.Background()) + pingErr := "" + if err != nil { + pingErr = err.Error() + } + return printValue(cmd, pingResponse{ + PingSucceed: success, + PingError: pingErr, + }) + }, +} + +type pingResponse struct { + PingSucceed bool `json:"ping_succeed"` + PingError string `json:"ping_error"` +} + +func (r pingResponse) String() string { + if r.PingSucceed { + return "✅ Ping succeeded" + } + return "❌ Ping failed with error: " + r.PingError +} + +func init() { + rootCmd.AddCommand(endpointPingCmd) +} diff --git a/cmd/hypersdk-cli/read.go b/cmd/hypersdk-cli/read.go new file mode 100644 index 0000000000..4ce610dd50 --- /dev/null +++ b/cmd/hypersdk-cli/read.go @@ -0,0 +1,270 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/abi" + "github.com/ava-labs/hypersdk/abi/dynamic" + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/cli/prompt" + "github.com/ava-labs/hypersdk/codec" +) + +var readCmd = &cobra.Command{ + Use: "read [action]", + Short: "Read data from the chain", + RunE: func(cmd *cobra.Command, args []string) error { + // 1. figure out sender address + senderStr, err := cmd.Flags().GetString("sender") + if err != nil { + return fmt.Errorf("failed to get sender: %w", err) + } + + var sender codec.Address + + if senderStr != "" { + sender, err = codec.StringToAddress(senderStr) + if err != nil { + return fmt.Errorf("failed to convert sender to address: %w", err) + } + } else { + // ok, infer user's address from the private key + keyString, err := getConfigValue(cmd, "key", true) + if err != nil { + return fmt.Errorf("failed to get key from config: %w", err) + } + key, err := privateKeyFromString(keyString) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + sender = auth.NewED25519Address(key.PublicKey()) + } + + // 2. create client + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + client := jsonrpc.NewJSONRPCClient(endpoint) + + // 3. get abi + abi, err := client.GetABI(context.Background()) + if err != nil { + return fmt.Errorf("failed to get abi: %w", err) + } + + // 4. get action name from args + if len(args) == 0 { + return errors.New("action name is required") + } + actionName := args[0] + _, found := abi.FindActionByName(actionName) + if !found { + return fmt.Errorf("failed to find action: %s", actionName) + } + + typ, found := abi.FindTypeByName(actionName) + if !found { + return fmt.Errorf("failed to find type: %s", actionName) + } + + // 5. create action using kvPairs + kvPairs, err := fillAction(cmd, typ) + if err != nil { + return err + } + + jsonPayload, err := json.Marshal(kvPairs) + if err != nil { + return fmt.Errorf("failed to marshal kvPairs: %w", err) + } + + actionBytes, err := dynamic.Marshal(abi, actionName, string(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to marshal action: %w", err) + } + + results, executeErr := client.ExecuteActions(context.Background(), sender, [][]byte{actionBytes}) + var resultStruct map[string]interface{} + + if len(results) == 1 { + resultJSON, err := dynamic.UnmarshalOutput(abi, results[0]) + if err != nil { + return fmt.Errorf("failed to unmarshal result: %w", err) + } + + err = json.Unmarshal([]byte(resultJSON), &resultStruct) + if err != nil { + return fmt.Errorf("failed to unmarshal result JSON: %w", err) + } + } + + errorString := "" + if executeErr != nil { + errorString = executeErr.Error() + } + + return printValue(cmd, readResponse{ + Result: resultStruct, + Success: executeErr == nil, + Error: errorString, + }) + }, +} + +func fillAction(cmd *cobra.Command, typ abi.Type) (map[string]interface{}, error) { + // get key-value pairs + inputData, err := cmd.Flags().GetStringToString("data") + if err != nil { + return nil, fmt.Errorf("failed to get data key-value pairs: %w", err) + } + + isJSONOutput, err := isJSONOutputRequested(cmd) + if err != nil { + return nil, fmt.Errorf("failed to get output format: %w", err) + } + + isInteractive := len(inputData) == 0 && !isJSONOutput + + var kvPairs map[string]interface{} + if isInteractive { + kvPairs, err = askForFlags(typ) + if err != nil { + return nil, fmt.Errorf("failed to ask for flags: %w", err) + } + } else { + kvPairs, err = fillFromInputData(typ, inputData) + if err != nil { + return nil, fmt.Errorf("failed to fill from kvData: %w", err) + } + } + + return kvPairs, nil +} + +func fillFromInputData(typ abi.Type, kvData map[string]string) (map[string]interface{}, error) { + // Require exact match in required fields to supplied arguments + if len(kvData) != len(typ.Fields) { + return nil, fmt.Errorf("type has %d fields, got %d arguments", len(typ.Fields), len(kvData)) + } + for _, field := range typ.Fields { + if _, ok := kvData[field.Name]; !ok { + return nil, fmt.Errorf("missing argument: %s", field.Name) + } + } + + kvPairs := make(map[string]interface{}) + for _, field := range typ.Fields { + value := kvData[field.Name] + var parsedValue interface{} + var err error + switch field.Type { + case "Address": + parsedValue = value + case "uint8", "uint16", "uint32", "uint", "uint64": + parsedValue, err = strconv.ParseUint(value, 10, 64) + case "int8", "int16", "int32", "int", "int64": + parsedValue, err = strconv.ParseInt(value, 10, 64) + case "[]uint8": + if value == "" { + parsedValue = []uint8{} + } else { + parsedValue, err = codec.LoadHex(value, -1) + } + case "string": + parsedValue = value + case "bool": + parsedValue, err = strconv.ParseBool(value) + default: + return nil, fmt.Errorf("unsupported field type: %s", field.Type) + } + if err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", field.Name, err) + } + kvPairs[field.Name] = parsedValue + } + return kvPairs, nil +} + +func askForFlags(typ abi.Type) (map[string]interface{}, error) { + kvPairs := make(map[string]interface{}) + + for _, field := range typ.Fields { + var err error + var value interface{} + switch field.Type { + case "Address": + value, err = prompt.Address(field.Name) + case "uint8": + value, err = prompt.Uint(field.Name, math.MaxUint8) + case "uint16": + value, err = prompt.Uint(field.Name, math.MaxUint16) + case "uint32": + value, err = prompt.Uint(field.Name, math.MaxUint32) + case "uint", "uint64": + value, err = prompt.Uint(field.Name, math.MaxUint64) + case "int8": + value, err = prompt.Int(field.Name, math.MaxInt8) + case "int16": + value, err = prompt.Int(field.Name, math.MaxInt16) + case "int32": + value, err = prompt.Int(field.Name, math.MaxInt32) + case "int", "int64": + value, err = prompt.Int(field.Name, math.MaxInt64) + case "[]uint8": + value, err = prompt.Bytes(field.Name) + case "string": + value, err = prompt.String(field.Name, 0, 1024) + case "bool": + value, err = prompt.Bool(field.Name) + default: + return nil, fmt.Errorf("unsupported field type in CLI: %s", field.Type) + } + if err != nil { + return nil, fmt.Errorf("failed to get input for %s field: %w", field.Name, err) + } + kvPairs[field.Name] = value + } + return kvPairs, nil +} + +type readResponse struct { + Result map[string]interface{} `json:"result"` + Success bool `json:"success"` + Error string `json:"error"` +} + +func (r readResponse) String() string { + var result strings.Builder + if r.Success { + result.WriteString("✅ Read-only execution successful:\n") + for key, value := range r.Result { + jsonValue, err := json.Marshal(value) + if err != nil { + jsonValue = []byte(fmt.Sprintf("%v", value)) + } + result.WriteString(fmt.Sprintf("%s: %s\n", key, string(jsonValue))) + } + } else { + result.WriteString(fmt.Sprintf("❌ Read-only execution failed: %s\n", r.Error)) + } + return result.String() +} + +func init() { + readCmd.Flags().String("sender", "", "Address of the sender in hex") + readCmd.Flags().StringToString("data", nil, "Key-value pairs for the action data (e.g., key1=value1,key2=value2)") + rootCmd.AddCommand(readCmd) +} diff --git a/cmd/hypersdk-cli/transaction.go b/cmd/hypersdk-cli/transaction.go new file mode 100644 index 0000000000..d8e6f7d097 --- /dev/null +++ b/cmd/hypersdk-cli/transaction.go @@ -0,0 +1,178 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/ava-labs/avalanchego/ids" + "github.com/spf13/cobra" + + "github.com/ava-labs/hypersdk/abi/dynamic" + "github.com/ava-labs/hypersdk/api/indexer" + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/chain" +) + +var txCmd = &cobra.Command{ + Use: "tx [action]", + Short: "Execute a transaction on the chain", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 1. Decode key + keyString, err := getConfigValue(cmd, "key", true) + if err != nil { + return fmt.Errorf("failed to get key from config: %w", err) + } + key, err := privateKeyFromString(keyString) + if err != nil { + return fmt.Errorf("failed to decode key: %w", err) + } + + // 2. create client + endpoint, err := getConfigValue(cmd, "endpoint", true) + if err != nil { + return fmt.Errorf("failed to get endpoint: %w", err) + } + client := jsonrpc.NewJSONRPCClient(endpoint) + + // 3. get abi + abi, err := client.GetABI(ctx) + if err != nil { + return fmt.Errorf("failed to get abi: %w", err) + } + // 4. get action name from args + if len(args) == 0 { + return errors.New("action name is required") + } + actionName := args[0] + _, found := abi.FindActionByName(actionName) + if !found { + return fmt.Errorf("failed to find action: %s", actionName) + } + + typ, found := abi.FindTypeByName(actionName) + if !found { + return fmt.Errorf("failed to find type: %s", actionName) + } + + // 5. create action using kvPairs + kvPairs, err := fillAction(cmd, typ) + if err != nil { + return err + } + + jsonPayload, err := json.Marshal(kvPairs) + if err != nil { + return fmt.Errorf("failed to marshal kvPairs: %w", err) + } + + actionBytes, err := dynamic.Marshal(abi, actionName, string(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to marshal action: %w", err) + } + + _, _, chainID, err := client.Network(ctx) + if err != nil { + return fmt.Errorf("failed to get network info: %w", err) + } + + base := &chain.Base{ + ChainID: chainID, + Timestamp: time.Now().Unix()*1000 + 60*1000, // TODO: use utils.UnixRMilli(now, rules.GetValidityWindow()) + MaxFee: 1_000_000, // TODO: use chain.EstimateUnits(parser.Rules(time.Now().UnixMilli()), actions, authFactory) + } + + signedBytes, err := chain.SignRawActionBytesTx(base, append([]byte{1}, actionBytes...), auth.NewED25519Factory(key)) + if err != nil { + return fmt.Errorf("failed to sign tx: %w", err) + } + + indexerClient := indexer.NewClient(endpoint) + + expectedTxID, err := client.SubmitTx(ctx, signedBytes) + if err != nil { + return fmt.Errorf("failed to send tx: %w", err) + } + + var getTxResponse indexer.GetTxResponse + for { + if err := ctx.Err(); err != nil { + return fmt.Errorf("context expired while waiting for tx: %w", err) + } + + getTxResponse, found, err = indexerClient.GetTx(ctx, expectedTxID) + if err != nil { + return fmt.Errorf("failed to get tx: %w", err) + } + if found { + break + } + time.Sleep(500 * time.Millisecond) + } + + var resultStruct map[string]interface{} + if getTxResponse.Success { + if len(getTxResponse.Outputs) == 1 { + resultJSON, err := dynamic.UnmarshalOutput(abi, getTxResponse.Outputs[0]) + if err != nil { + return fmt.Errorf("failed to unmarshal result: %w", err) + } + + err = json.Unmarshal([]byte(resultJSON), &resultStruct) + if err != nil { + return fmt.Errorf("failed to unmarshal result JSON: %w", err) + } + } else if len(getTxResponse.Outputs) > 1 { + return fmt.Errorf("expected 1 output, got %d", len(getTxResponse.Outputs)) + } + } + + return printValue(cmd, txResponse{ + Result: resultStruct, + Success: getTxResponse.Success, + TxID: expectedTxID, + Error: getTxResponse.ErrorStr, + }) + }, +} + +type txResponse struct { + Result map[string]interface{} `json:"result"` + Success bool `json:"success"` + TxID ids.ID `json:"txId"` + Error string `json:"error"` +} + +func (r txResponse) String() string { + var result strings.Builder + if r.Success { + result.WriteString(fmt.Sprintf("✅ Transaction successful (txID: %s)\n", r.TxID)) + if r.Result != nil { + for key, value := range r.Result { + jsonValue, err := json.Marshal(value) + if err != nil { + jsonValue = []byte(fmt.Sprintf("%v", value)) + } + result.WriteString(fmt.Sprintf("%s: %s\n", key, string(jsonValue))) + } + } + } else { + result.WriteString(fmt.Sprintf("❌ Transaction failed (txID: %s): %s\n", r.TxID, r.Error)) + } + return strings.TrimSpace(result.String()) +} + +func init() { + txCmd.Flags().StringToString("data", nil, "Key-value pairs for the action data (e.g., key1=value1,key2=value2)") + rootCmd.AddCommand(txCmd) +} diff --git a/codec/errors.go b/codec/errors.go index 708b32d6d1..0fe55f6afb 100644 --- a/codec/errors.go +++ b/codec/errors.go @@ -9,7 +9,6 @@ var ( ErrTooManyItems = errors.New("too many items") ErrDuplicateItem = errors.New("duplicate item") ErrFieldNotPopulated = errors.New("field is not populated") - ErrIncorrectHRP = errors.New("incorrect hrp") ErrInsufficientLength = errors.New("insufficient length") ErrInvalidSize = errors.New("invalid size") ) diff --git a/codec/hex.go b/codec/hex.go index 44d2e5d479..11bb929ece 100644 --- a/codec/hex.go +++ b/codec/hex.go @@ -13,6 +13,10 @@ func ToHex(b []byte) string { // LoadHex Converts hex encoded string into bytes. Returns // an error if key is invalid. func LoadHex(s string, expectedSize int) ([]byte, error) { + if len(s) >= 2 && s[:2] == "0x" { + s = s[2:] + } + bytes, err := hex.DecodeString(s) if err != nil { return nil, err diff --git a/codec/hex_test.go b/codec/hex_test.go index a610df8dc4..cfa0138bcd 100644 --- a/codec/hex_test.go +++ b/codec/hex_test.go @@ -29,3 +29,18 @@ func TestBytesHex(t *testing.T) { require.NoError(json.Unmarshal(jsonMarshalledBytes, &jsonUnmarshalledBytes)) require.Equal(b, []byte(jsonUnmarshalledBytes)) } + +func TestLoadHex(t *testing.T) { + require := require.New(t) + + var actual []byte + var err error + + actual, err = LoadHex("0x1234", 2) + require.NoError(err) + require.Equal([]byte{0x12, 0x34}, actual) + + actual, err = LoadHex("1234", 2) + require.NoError(err) + require.Equal([]byte{0x12, 0x34}, actual) +} diff --git a/consts/consts.go b/consts/consts.go index 377c8f8d21..96d6419d37 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -21,6 +21,7 @@ const ( // no more than 50 KiB of overhead but is likely much less) NetworkSizeLimit = 2_044_723 // 1.95 MiB + // FIXME: should use the standard math.MaxUint8, etc. MaxUint8 = ^uint8(0) MaxUint16 = ^uint16(0) MaxUint8Offset = 7 diff --git a/docs/tutorials/morpheusvm/morpheusvm.md b/docs/tutorials/morpheusvm/morpheusvm.md index 97b227a690..1f9aae0f53 100644 --- a/docs/tutorials/morpheusvm/morpheusvm.md +++ b/docs/tutorials/morpheusvm/morpheusvm.md @@ -21,7 +21,7 @@ This introduction will go over everything up to and including the section ## Prerequisites -- You are using Go 1.21.12 +- You are using Go 1.22.8 To confirm, you can run: @@ -32,7 +32,7 @@ go version And you should see the ouptut (slightly different depending on your machine): ``` -go version go1.21.12 darwin/arm64 +go version go1.22.8 darwin/arm64 ``` ## Cloning HyperSDK @@ -61,8 +61,7 @@ values: mkdir consts ``` -Inside `consts/`, we'll add a `consts.go` file to declare the name, HRP for Bech32 addresses, -and the initial version of our VM: +Inside `consts/`, we'll add a `consts.go` file to declare the name and the initial version of our VM: ```golang package consts @@ -73,7 +72,6 @@ import ( ) const ( - HRP = "tutorial" Name = "tutorialvm" ) @@ -127,7 +125,6 @@ mkdir actions In `actions/`, create `transfer.go`. This file will declare the struct and add a type assertion that it implements the `chain.Action` interface: - ```golang package actions @@ -637,7 +634,7 @@ func (*Transfer) GetTypeID() uint8 { We import the following into `transfer.go`: ```golang - mconsts "github.com/ava-labs/hypersdk/examples/tutorial/consts" + mconsts "github.com/ava-labs/hypersdk/examples/tutorial/consts" ``` ### `StateKeys()` @@ -654,7 +651,6 @@ Beforehand, we need to import the `storage` package from earlier: "github.com/ava-labs/hypersdk/examples/tutorial/storage" ``` - ```golang func (t *Transfer) StateKeys(actor codec.Address, _ ids.ID) state.Keys { return state.Keys{ @@ -716,7 +712,7 @@ func (t *Transfer) Execute( The `ComputeUnits` function specifies how many units of computation are consumed by this action. The HyperSDK uses dynamic, multi-dimensional fees and specifies its -own resource limits ie. units of compute, storage, and bandwidth. This means it's +own resource limits ie. units of compute, storage, and bandwidth. This means it's more important that `ComputeUnits` of different actions is proportional to the maximum, target, and the cost of other actions than it is to be exact. So, we'll simply return 1 for `Transfer`. diff --git a/docs/tutorials/morpheusvm/options.md b/docs/tutorials/morpheusvm/options.md index 53fc743f67..7a38196f33 100644 --- a/docs/tutorials/morpheusvm/options.md +++ b/docs/tutorials/morpheusvm/options.md @@ -260,15 +260,15 @@ func (p *Parser) Rules(_ int64) chain.Rules { return p.genesis.Rules } -func (*Parser) ActionRegistry() chain.ActionRegistry { +func (*Parser) ActionCodec() chain.ActionCodec { return ActionParser } -func (*Parser) OutputRegistry() chain.OutputRegistry { +func (*Parser) OutputCodec() chain.OutputCodec { return OutputParser } -func (*Parser) AuthRegistry() chain.AuthRegistry { +func (*Parser) AuthCodec() chain.AuthCodec { return AuthParser } diff --git a/examples/morpheusvm/README.md b/examples/morpheusvm/README.md index bfe4fae0ca..def83567e8 100644 --- a/examples/morpheusvm/README.md +++ b/examples/morpheusvm/README.md @@ -108,7 +108,7 @@ This should return the following JSON: } ``` -_By default, this allocates all funds on the network to `morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu`. The private +_By default, this allocates all funds on the network to `0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9`. The private key for this address is `0x323b1d8f4eed5f0da9da93071b034f2dce9d2d22692c172f3cb252a64ddfafd01b057de320297c29ad0c1f589ea216869cf1938d88c9fbd70d6748323dbf2fa7`. For convenience, this key has is also stored at `demo.pk`._ @@ -162,7 +162,7 @@ Next, you'll need to add the chains you created and the default key to the If the key is added corretcly, you'll see the following log: ``` database: .morpheus-cli -imported address: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu +imported address: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 ``` Next, you'll need to store the URL of the nodes running on your Subnet: @@ -211,10 +211,10 @@ If successful, the balance response should look like this: ``` database: .morpheus-cli 2024/09/09 10:52:49 [JOB 1] WAL file .morpheus-cli/000044.log with log number 000044 stopped reading at offset: 0; replayed 0 keys in 0 batches -address: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu +address: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 chainID: JkJpw8ZPExTushPYYN4C8f7RHxjDRX8MAGGUGAdRRPEC2M3fx uri: http://127.0.0.1:9650/ext/bc/morpheusvm -address: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu balance: 1000.000000000 RED +address: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 balance: 1000.000000000 RED ``` ### Generate Another Address @@ -243,7 +243,7 @@ database: .morpheus-cli 2024/09/09 10:53:51 [JOB 1] WAL file .morpheus-cli/000047.log with log number 000047 stopped reading at offset: 0; replayed 0 keys in 0 batches chainID: JkJpw8ZPExTushPYYN4C8f7RHxjDRX8MAGGUGAdRRPEC2M3fx stored keys: 2 -0) address: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu balance: 10000000000.000000000 RED +0) address: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 balance: 10000000000.000000000 RED 1) address: morpheus1q8pyshaqzx4q9stqdt88hyg22axjwrvl0w9wgczct5fnfev9gcnrsqwjdn0 balance: 0.000000000 RED set default key: 0 ``` @@ -285,7 +285,40 @@ select chainID: 0 uri: http://127.0.0.1:9650/ext/bc/morpheusvm watching for new blocks on 2mQy8Q9Af9dtZvVM8pKsh2rB3cT3QNLjghpet5Mm5db4N7Hwgk 👀 height:1 txs:1 units:440 root:WspVPrHNAwBcJRJPVwt7TW6WT4E74dN8DuD3WXueQTMt5FDdi -✅ sceRdaoqu2AAyLdHCdQkENZaXngGjRoc8nFdGyG8D9pCbTjbk actor: morpheus1qrzvk4zlwj9zsacqgtufx7zvapd3quufqpxk5rsdd4633m4wz2fdjk97rwu units: 440 summary (*actions.Transfer): [10.000000000 RED -> morpheus1q8rc050907hx39vfejpawjydmwe6uujw0njx9s6skzdpp3cm2he5s036p07] +✅ sceRdaoqu2AAyLdHCdQkENZaXngGjRoc8nFdGyG8D9pCbTjbk actor: 0x00c4cb545f748a28770042f893784ce85b107389004d6a0e0d6d7518eeae1292d9 units: 440 summary (*actions.Transfer): [10.000000000 RED -> morpheus1q8rc050907hx39vfejpawjydmwe6uujw0njx9s6skzdpp3cm2he5s036p07] +``` + +If you are running this on a local network, you may see that all blocks are empty. +To view the same results with non-empty transactions, you can run the spam command from another window: +```bash +./build/morpheus-cli spam run ed25519 +``` + +After inputting the parameters, you'll see output from the spam command showing inflight transactions: + +``` +minimum txs to issue per second: 10 +txs to increase per second: 10 +number of clients per node: 10 +unique participants expected every 60s: 10 +distributing funds to each account: 999.978355900 RED +distributed funds to 10 accounts +initial target tps: 10 +txs seen: 10 success rate: 100.00% inflight: 10 issued/s: 21 unit prices: [bandwidth=100 compute=100 storage(read)=100 storage(allocate)=100 storage(write)=100] +``` + +The transactions will start to show up in the CLI explorer tool as well: + +``` +watching for new blocks on bsED31vJbynkhzKrjtNFtUXcg6Y78MjQLg1KBgesMw1xom8F9 👀 +height:634 txs:0 root:B6upYeCegoR5bvfUGLo9GqbymyBCgXNobpTk53HBFNBZGG4aM size:0.08KB units consumed: [bandwidth=0 compute=0 storage(read)=0 storage(allocate)=0 storage(write)=0] unit prices: [bandwidth=100 compute=100 storage(read)=100 storage(allocate)=100 storage(write)=100] +height:635 txs:10 root:2W6eEZQYqD9xCsZoDPjASqtZx25fyUUobpesuKJJAxnFMU4RJi size:2.04KB units consumed: [bandwidth=2000 compute=70 storage(read)=140 storage(allocate)=500 storage(write)=260] unit prices: [bandwidth=100 compute=100 storage(read)=100 storage(allocate)=100 storage(write)=100] [TPS:85.69 latency:81ms gap:116ms] +✅ 2QEttqwxZyMF8Lzyj44wpsEhpbVyASq8c7Z4ogh3DDrWDKgKyh actor: 0090dc1ecabfc7680d68bc226158095861544b9309b251eed2f3d2425bc991285f summary (*actions.Transfer): [0.000000001 RED -> 0090dc1ecabfc7680d68bc226158095861544b9309b251eed2f3d2425bc991285f +] fee (max 86.84%): 0.000029700 RED consumed: [bandwidth=200 compute=7 storage(read)=14 storage(allocate)=50 storage(write)=26] +✅ 2HVNY8gbeBCrNiekTReGpKZ9hqkZea8Wf4VGjbdroorwEY7s7u actor: 00bd82f4be137f29222695f693e72a9e85e83510e575a3e485eb306a8ad5999010 summary (*actions.Transfer): [0.000000001 RED -> 00bd82f4be137f29222695f693e72a9e85e83510e575a3e485eb306a8ad5999010 +] fee (max 86.84%): 0.000029700 RED consumed: [bandwidth=200 compute=7 storage(read)=14 storage(allocate)=50 storage(write)=26] +✅ 2WXLjEXf25WeinidC9qmghZWbCeDa26F8pwwkFb53MSsEQm1NL actor: 0090dc1ecabfc7680d68bc226158095861544b9309b251eed2f3d2425bc991285f summary (*actions.Transfer): [0.000000001 RED -> 0090dc1ecabfc7680d68bc226158095861544b9309b251eed2f3d2425bc991285f +] fee (max 86.84%): 0.000029700 RED consumed: [bandwidth=200 compute=7 storage(read)=14 storage(allocate)=50 storage(write)=26] ``` If you are running this on a local network, you may see that all blocks are empty. diff --git a/examples/morpheusvm/actions/transfer.go b/examples/morpheusvm/actions/transfer.go index 7ccd262a80..c0a2965805 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" @@ -44,7 +43,7 @@ func (*Transfer) GetTypeID() uint8 { return mconsts.TransferID } -func (t *Transfer) StateKeys(actor codec.Address) state.Keys { +func (t *Transfer) StateKeys(actor codec.Address, _ ids.ID) state.Keys { return state.Keys{ string(storage.BalanceKey(actor)): state.Read | state.Write, string(storage.BalanceKey(t.To)): state.All, @@ -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/actions/transfer_test.go b/examples/morpheusvm/actions/transfer_test.go index e232797587..09c1dd7def 100644 --- a/examples/morpheusvm/actions/transfer_test.go +++ b/examples/morpheusvm/actions/transfer_test.go @@ -32,14 +32,14 @@ func TestTransferAction(t *testing.T) { ExpectedErr: ErrOutputValueZero, }, { - Name: "InvalidAddress", + Name: "NonExistentAddress", Actor: codec.EmptyAddress, Action: &Transfer{ To: codec.EmptyAddress, Value: 1, }, State: chaintest.NewInMemoryStore(), - ExpectedErr: storage.ErrInvalidAddress, + ExpectedErr: storage.ErrInvalidBalance, }, { Name: "NotEnoughBalance", @@ -130,6 +130,64 @@ func TestTransferAction(t *testing.T) { } } +// TestMultiTransfer shows an example of reusing the same store for multiple sequential action invocations. +func TestMultiTransfer(t *testing.T) { + addrAlice := codectest.NewRandomAddress() + addrBob := codectest.NewRandomAddress() + + store := chaintest.NewInMemoryStore() + require.NoError(t, storage.SetBalance(context.Background(), store, addrAlice, 1)) + + tests := []chaintest.ActionTest{ + { + Name: "TransferToBob", + Actor: addrAlice, + Action: &Transfer{ + To: addrBob, + Value: 1, + }, + State: store, + Assertion: func(ctx context.Context, t *testing.T, store state.Mutable) { + receiverBalance, err := storage.GetBalance(ctx, store, addrBob) + require.NoError(t, err) + require.Equal(t, receiverBalance, uint64(1)) + senderBalance, err := storage.GetBalance(ctx, store, addrAlice) + require.NoError(t, err) + require.Equal(t, senderBalance, uint64(0)) + }, + ExpectedOutputs: &TransferResult{ + SenderBalance: 0, + ReceiverBalance: 1, + }, + }, + { + Name: "TransferToAlice", + Actor: addrBob, + Action: &Transfer{ + To: addrAlice, + Value: 1, + }, + State: store, + Assertion: func(ctx context.Context, t *testing.T, store state.Mutable) { + receiverBalance, err := storage.GetBalance(ctx, store, addrAlice) + require.NoError(t, err) + require.Equal(t, receiverBalance, uint64(1)) + senderBalance, err := storage.GetBalance(ctx, store, addrBob) + require.NoError(t, err) + require.Equal(t, senderBalance, uint64(0)) + }, + ExpectedOutputs: &TransferResult{ + SenderBalance: 0, + ReceiverBalance: 1, + }, + }, + } + + for _, tt := range tests { + tt.Run(context.Background(), t) + } +} + func BenchmarkSimpleTransfer(b *testing.B) { setupRequire := require.New(b) to := codec.CreateAddress(0, ids.GenerateTestID()) 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/consts/consts.go b/examples/morpheusvm/consts/consts.go index b2f67737b0..7bd709af30 100644 --- a/examples/morpheusvm/consts/consts.go +++ b/examples/morpheusvm/consts/consts.go @@ -9,10 +9,8 @@ import ( ) const ( - HRP = "morpheus" - Name = "morpheusvm" - Symbol = "RED" - Decimals = 9 + Name = "morpheusvm" + Symbol = "RED" ) var ID ids.ID diff --git a/examples/morpheusvm/go.mod b/examples/morpheusvm/go.mod index 60cca14fb0..1ecaaba43a 100644 --- a/examples/morpheusvm/go.mod +++ b/examples/morpheusvm/go.mod @@ -1,6 +1,6 @@ module github.com/ava-labs/hypersdk/examples/morpheusvm -go 1.21.12 +go 1.22.8 require ( github.com/ava-labs/avalanchego v1.11.12-rc.2.0.20241001202925-f03745d187d0 diff --git a/examples/morpheusvm/storage/state_manager.go b/examples/morpheusvm/storage/state_manager.go index 630481a916..c5d9cbc8cd 100644 --- a/examples/morpheusvm/storage/state_manager.go +++ b/examples/morpheusvm/storage/state_manager.go @@ -11,29 +11,17 @@ import ( "github.com/ava-labs/hypersdk/state" ) -var _ (chain.StateManager) = (*StateManager)(nil) +var _ (chain.BalanceHandler) = (*BalanceHandler)(nil) -type StateManager struct{} +type BalanceHandler struct{} -func (*StateManager) HeightKey() []byte { - return HeightKey() -} - -func (*StateManager) TimestampKey() []byte { - return TimestampKey() -} - -func (*StateManager) FeeKey() []byte { - return FeeKey() -} - -func (*StateManager) SponsorStateKeys(addr codec.Address) state.Keys { +func (*BalanceHandler) SponsorStateKeys(addr codec.Address) state.Keys { return state.Keys{ string(BalanceKey(addr)): state.Read | state.Write, } } -func (*StateManager) CanDeduct( +func (*BalanceHandler) CanDeduct( ctx context.Context, addr codec.Address, im state.Immutable, @@ -49,7 +37,7 @@ func (*StateManager) CanDeduct( return nil } -func (*StateManager) Deduct( +func (*BalanceHandler) Deduct( ctx context.Context, addr codec.Address, mu state.Mutable, @@ -59,7 +47,7 @@ func (*StateManager) Deduct( return err } -func (*StateManager) AddBalance( +func (*BalanceHandler) AddBalance( ctx context.Context, addr codec.Address, mu state.Mutable, @@ -69,3 +57,7 @@ func (*StateManager) AddBalance( _, err := AddBalance(ctx, mu, addr, amount, createAccount) return err } + +func (*BalanceHandler) GetBalance(ctx context.Context, addr codec.Address, im state.Immutable) (uint64, error) { + return GetBalance(ctx, im, addr) +} diff --git a/examples/morpheusvm/storage/storage.go b/examples/morpheusvm/storage/storage.go index 5c7ff3914b..b366a8cea7 100644 --- a/examples/morpheusvm/storage/storage.go +++ b/examples/morpheusvm/storage/storage.go @@ -14,6 +14,7 @@ import ( "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/consts" "github.com/ava-labs/hypersdk/state" + "github.com/ava-labs/hypersdk/state/metadata" smath "github.com/ava-labs/avalanchego/utils/math" ) @@ -21,29 +22,16 @@ import ( type ReadState func(context.Context, [][]byte) ([][]byte, []error) // State -// / (height) => store in root -// -> [heightPrefix] => height -// 0x0/ (balance) +// 0x0/ (hypersdk-height) +// 0x1/ (hypersdk-timestamp) +// 0x2/ (hypersdk-fee) +// +// 0x3/ (balance) // -> [owner] => balance -// 0x1/ (hypersdk-height) -// 0x2/ (hypersdk-timestamp) -// 0x3/ (hypersdk-fee) - -const ( - // Active state - balancePrefix = 0x0 - heightPrefix = 0x1 - timestampPrefix = 0x2 - feePrefix = 0x3 -) -const BalanceChunks uint16 = 1 +const balancePrefix byte = metadata.DefaultMinimumPrefix -var ( - heightKey = []byte{heightPrefix} - timestampKey = []byte{timestampPrefix} - feeKey = []byte{feePrefix} -) +const BalanceChunks uint16 = 1 // [balancePrefix] + [address] func BalanceKey(addr codec.Address) (k []byte) { @@ -159,7 +147,7 @@ func SubBalance( ) (uint64, error) { key, bal, ok, err := getBalance(ctx, mu, addr) if !ok { - return 0, ErrInvalidAddress + return 0, ErrInvalidBalance } if err != nil { return 0, err @@ -181,15 +169,3 @@ func SubBalance( } return nbal, setBalance(ctx, mu, key, nbal) } - -func HeightKey() (k []byte) { - return heightKey -} - -func TimestampKey() (k []byte) { - return timestampKey -} - -func FeeKey() (k []byte) { - return feeKey -} diff --git a/examples/morpheusvm/tests/e2e/e2e_test.go b/examples/morpheusvm/tests/e2e/e2e_test.go index 8c0d85b327..a962aa2161 100644 --- a/examples/morpheusvm/tests/e2e/e2e_test.go +++ b/examples/morpheusvm/tests/e2e/e2e_test.go @@ -4,15 +4,19 @@ package e2e_test import ( - "encoding/json" "testing" + "time" "github.com/ava-labs/avalanchego/tests/fixture/e2e" "github.com/stretchr/testify/require" + _ "github.com/ava-labs/hypersdk/examples/morpheusvm/tests" // include the tests that are shared between the integration and e2e + "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,25 +40,28 @@ func init() { var _ = ginkgo.SynchronizedBeforeSuite(func() []byte { require := require.New(ginkgo.GinkgoT()) - gen, workloadFactory, err := workload.New(100 /* minBlockGap: 100ms */) - require.NoError(err) - - genesisBytes, err := json.Marshal(gen) + testingNetworkConfig, err := workload.NewTestNetworkConfig(100 * time.Millisecond) require.NoError(err) expectedABI, err := abi.NewABI(vm.ActionParser.GetRegisteredTypes(), vm.OutputParser.GetRegisteredTypes()) require.NoError(err) - parser, err := vm.CreateParser(genesisBytes) - require.NoError(err) - // 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, + } + + firstKey := testingNetworkConfig.Keys()[0] + generator := workload.NewTxGenerator(firstKey) + spamKey := &auth.PrivateKey{ + Address: auth.NewED25519Address(firstKey.PublicKey()), + Bytes: firstKey[:], + } tc := e2e.NewTestContext() + he2e.SetWorkload(testingNetworkConfig, generator, expectedABI, &spamHelper, spamKey) - return fixture.NewTestEnvironment(tc, flagVars, owner, consts.Name, consts.ID, genesisBytes).Marshal() + return fixture.NewTestEnvironment(tc, flagVars, owner, testingNetworkConfig, consts.ID).Marshal() }, func(envBytes []byte) { // Run in every ginkgo process diff --git a/examples/morpheusvm/tests/integration/integration_test.go b/examples/morpheusvm/tests/integration/integration_test.go index ad65e27433..65e44ac0d5 100644 --- a/examples/morpheusvm/tests/integration/integration_test.go +++ b/examples/morpheusvm/tests/integration/integration_test.go @@ -4,18 +4,19 @@ package integration_test import ( - "encoding/json" "testing" "github.com/stretchr/testify/require" + _ "github.com/ava-labs/hypersdk/examples/morpheusvm/tests" // include the tests that are shared between the integration and e2e + "github.com/ava-labs/hypersdk/auth" "github.com/ava-labs/hypersdk/crypto/ed25519" + "github.com/ava-labs/hypersdk/examples/morpheusvm/tests/workload" "github.com/ava-labs/hypersdk/examples/morpheusvm/vm" "github.com/ava-labs/hypersdk/tests/integration" lconsts "github.com/ava-labs/hypersdk/examples/morpheusvm/consts" - morpheusWorkload "github.com/ava-labs/hypersdk/examples/morpheusvm/tests/workload" ginkgo "github.com/onsi/ginkgo/v2" ) @@ -25,10 +26,8 @@ func TestIntegration(t *testing.T) { var _ = ginkgo.BeforeSuite(func() { require := require.New(ginkgo.GinkgoT()) - genesis, workloadFactory, err := morpheusWorkload.New(0 /* minBlockGap: 0ms */) - require.NoError(err) - genesisBytes, err := json.Marshal(genesis) + testingNetworkConfig, err := workload.NewTestNetworkConfig(0) require.NoError(err) randomEd25519Priv, err := ed25519.GeneratePrivateKey() @@ -36,13 +35,13 @@ var _ = ginkgo.BeforeSuite(func() { randomEd25519AuthFactory := auth.NewED25519Factory(randomEd25519Priv) + generator := workload.NewTxGenerator(testingNetworkConfig.Keys()[0]) // Setup imports the integration test coverage integration.Setup( vm.New, - genesisBytes, + testingNetworkConfig, lconsts.ID, - vm.CreateParser, - workloadFactory, + generator, randomEd25519AuthFactory, ) }) diff --git a/examples/morpheusvm/tests/transfer.go b/examples/morpheusvm/tests/transfer.go new file mode 100644 index 0000000000..ebf8fa5d73 --- /dev/null +++ b/examples/morpheusvm/tests/transfer.go @@ -0,0 +1,49 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package tests + +import ( + "context" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/crypto/ed25519" + "github.com/ava-labs/hypersdk/examples/morpheusvm/actions" + "github.com/ava-labs/hypersdk/examples/morpheusvm/tests/workload" + "github.com/ava-labs/hypersdk/tests/registry" + + tworkload "github.com/ava-labs/hypersdk/tests/workload" + ginkgo "github.com/onsi/ginkgo/v2" +) + +// TestsRegistry initialized during init to ensure tests are identical during ginkgo +// suite construction and test execution +// ref https://onsi.github.io/ginkgo/#mental-model-how-ginkgo-traverses-the-spec-hierarchy +var TestsRegistry = ®istry.Registry{} + +var _ = registry.Register(TestsRegistry, "Transfer Transaction", func(t ginkgo.FullGinkgoTInterface, tn tworkload.TestNetwork) { + require := require.New(t) + other, err := ed25519.GeneratePrivateKey() + require.NoError(err) + toAddress := auth.NewED25519Address(other.PublicKey()) + + networkConfig := tn.Configuration().(*workload.NetworkConfiguration) + spendingKey := networkConfig.Keys()[0] + + tx, err := tn.GenerateTx(context.Background(), []chain.Action{&actions.Transfer{ + To: toAddress, + Value: 1, + }}, + auth.NewED25519Factory(spendingKey), + ) + require.NoError(err) + + timeoutCtx, timeoutCtxFnc := context.WithDeadline(context.Background(), time.Now().Add(2*time.Second)) + defer timeoutCtxFnc() + + require.NoError(tn.ConfirmTxs(timeoutCtx, []*chain.Transaction{tx})) +}) diff --git a/examples/morpheusvm/tests/workload/generator.go b/examples/morpheusvm/tests/workload/generator.go new file mode 100644 index 0000000000..6ca9f15f16 --- /dev/null +++ b/examples/morpheusvm/tests/workload/generator.go @@ -0,0 +1,94 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package workload + +import ( + "context" + "time" + + "github.com/ava-labs/avalanchego/ids" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/hypersdk/api/indexer" + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/crypto/ed25519" + "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/tests/workload" +) + +var _ workload.TxGenerator = (*TxGenerator)(nil) + +const txCheckInterval = 100 * time.Millisecond + +type TxGenerator struct { + factory *auth.ED25519Factory +} + +func NewTxGenerator(key ed25519.PrivateKey) *TxGenerator { + return &TxGenerator{ + factory: auth.NewED25519Factory(key), + } +} + +func (g *TxGenerator) GenerateTx(ctx context.Context, uri string) (*chain.Transaction, workload.TxAssertion, error) { + // TODO: no need to generate the clients every tx + cli := jsonrpc.NewJSONRPCClient(uri) + lcli := vm.NewJSONRPCClient(uri) + + to, err := ed25519.GeneratePrivateKey() + if err != nil { + return nil, nil, err + } + + toAddress := auth.NewED25519Address(to.PublicKey()) + parser, err := lcli.Parser(ctx) + if err != nil { + return nil, nil, err + } + _, tx, _, err := cli.GenerateTransaction( + ctx, + parser, + []chain.Action{&actions.Transfer{ + To: toAddress, + Value: 1, + }}, + g.factory, + ) + if err != nil { + return nil, nil, err + } + + return tx, func(ctx context.Context, require *require.Assertions, uri string) { + confirmTx(ctx, require, uri, tx.ID(), toAddress, 1) + }, nil +} + +func confirmTx(ctx context.Context, require *require.Assertions, uri string, txID ids.ID, receiverAddr codec.Address, receiverExpectedBalance uint64) { + indexerCli := indexer.NewClient(uri) + success, _, err := indexerCli.WaitForTransaction(ctx, txCheckInterval, txID) + require.NoError(err) + require.True(success) + lcli := vm.NewJSONRPCClient(uri) + balance, err := lcli.Balance(ctx, receiverAddr) + require.NoError(err) + require.Equal(receiverExpectedBalance, balance) + txRes, _, err := indexerCli.GetTx(ctx, txID) + require.NoError(err) + // TODO: perform exact expected fee, units check, and output check + require.NotZero(txRes.Fee) + require.Len(txRes.Outputs, 1) + transferOutputBytes := []byte(txRes.Outputs[0]) + require.Equal(consts.TransferID, transferOutputBytes[0]) + reader := codec.NewReader(transferOutputBytes, len(transferOutputBytes)) + transferOutputTyped, err := vm.OutputParser.Unmarshal(reader) + require.NoError(err) + transferOutput, ok := transferOutputTyped.(*actions.TransferResult) + require.True(ok) + require.Equal(receiverExpectedBalance, transferOutput.ReceiverBalance) +} diff --git a/examples/morpheusvm/tests/workload/genesis.go b/examples/morpheusvm/tests/workload/genesis.go new file mode 100644 index 0000000000..8d3f8fd49e --- /dev/null +++ b/examples/morpheusvm/tests/workload/genesis.go @@ -0,0 +1,98 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package workload + +import ( + "encoding/json" + "math" + "time" + + "github.com/ava-labs/avalanchego/ids" + + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/crypto/ed25519" + "github.com/ava-labs/hypersdk/examples/morpheusvm/consts" + "github.com/ava-labs/hypersdk/examples/morpheusvm/vm" + "github.com/ava-labs/hypersdk/fees" + "github.com/ava-labs/hypersdk/genesis" + "github.com/ava-labs/hypersdk/tests/workload" +) + +const ( + // default initial balance for each address + InitialBalance uint64 = 10_000_000_000_000 +) + +var _ workload.TestNetworkConfiguration = &NetworkConfiguration{} + +// hardcoded initial set of ed25519 keys. Each will be initialized with InitialBalance +var ed25519HexKeys = []string{ + "323b1d8f4eed5f0da9da93071b034f2dce9d2d22692c172f3cb252a64ddfafd01b057de320297c29ad0c1f589ea216869cf1938d88c9fbd70d6748323dbf2fa7", //nolint:lll + "8a7be2e0c9a2d09ac2861c34326d6fe5a461d920ba9c2b345ae28e603d517df148735063f8d5d8ba79ea4668358943e5c80bc09e9b2b9a15b5b15db6c1862e88", //nolint:lll +} + +func newGenesis(keys []ed25519.PrivateKey, minBlockGap time.Duration) *genesis.DefaultGenesis { + // allocate the initial balance to the addresses + customAllocs := make([]*genesis.CustomAllocation, 0, len(keys)) + for _, key := range keys { + customAllocs = append(customAllocs, &genesis.CustomAllocation{ + Address: auth.NewED25519Address(key.PublicKey()), + Balance: InitialBalance, + }) + } + + 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} + + // Set all limits to MaxUint64 to avoid limiting block size for all dimensions except bandwidth. Must limit bandwidth to avoid building + // a block that exceeds the maximum size allowed by AvalancheGo. + genesis.Rules.MaxBlockUnits = fees.Dimensions{1800000, math.MaxUint64, math.MaxUint64, math.MaxUint64, math.MaxUint64} + genesis.Rules.MinBlockGap = minBlockGap.Milliseconds() + + genesis.Rules.NetworkID = uint32(1) + genesis.Rules.ChainID = ids.GenerateTestID() + + return genesis +} + +func newDefaultKeys() []ed25519.PrivateKey { + testKeys := make([]ed25519.PrivateKey, len(ed25519HexKeys)) + for i, keyHex := range ed25519HexKeys { + bytes, err := codec.LoadHex(keyHex, ed25519.PrivateKeyLen) + if err != nil { + panic(err) + } + testKeys[i] = ed25519.PrivateKey(bytes) + } + + return testKeys +} + +type NetworkConfiguration struct { + workload.DefaultTestNetworkConfiguration + keys []ed25519.PrivateKey +} + +func (n *NetworkConfiguration) Keys() []ed25519.PrivateKey { + return n.keys +} + +func NewTestNetworkConfig(minBlockGap time.Duration) (*NetworkConfiguration, error) { + keys := newDefaultKeys() + genesis := newGenesis(keys, minBlockGap) + genesisBytes, err := json.Marshal(genesis) + if err != nil { + return nil, err + } + return &NetworkConfiguration{ + DefaultTestNetworkConfiguration: workload.NewDefaultTestNetworkConfiguration( + genesisBytes, + consts.Name, + vm.NewParser(genesis)), + keys: keys, + }, nil +} diff --git a/examples/morpheusvm/tests/workload/workload.go b/examples/morpheusvm/tests/workload/workload.go deleted file mode 100644 index 77a821ce5d..0000000000 --- a/examples/morpheusvm/tests/workload/workload.go +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. -// See the file LICENSE for licensing terms. - -package workload - -import ( - "context" - "math" - "time" - - "github.com/ava-labs/avalanchego/ids" - "github.com/stretchr/testify/require" - - "github.com/ava-labs/hypersdk/api/indexer" - "github.com/ava-labs/hypersdk/api/jsonrpc" - "github.com/ava-labs/hypersdk/auth" - "github.com/ava-labs/hypersdk/chain" - "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/fees" - "github.com/ava-labs/hypersdk/genesis" - "github.com/ava-labs/hypersdk/tests/workload" -) - -const ( - initialBalance uint64 = 10_000_000_000_000 - txCheckInterval = 100 * time.Millisecond -) - -var ( - _ workload.TxWorkloadFactory = (*workloadFactory)(nil) - _ workload.TxWorkloadIterator = (*simpleTxWorkload)(nil) - ed25519HexKeys = []string{ - "323b1d8f4eed5f0da9da93071b034f2dce9d2d22692c172f3cb252a64ddfafd01b057de320297c29ad0c1f589ea216869cf1938d88c9fbd70d6748323dbf2fa7", //nolint:lll - "8a7be2e0c9a2d09ac2861c34326d6fe5a461d920ba9c2b345ae28e603d517df148735063f8d5d8ba79ea4668358943e5c80bc09e9b2b9a15b5b15db6c1862e88", //nolint:lll - } - ed25519PrivKeys = make([]ed25519.PrivateKey, len(ed25519HexKeys)) - ed25519Addrs = make([]codec.Address, len(ed25519HexKeys)) - ed25519AuthFactories = make([]*auth.ED25519Factory, len(ed25519HexKeys)) -) - -func init() { - for i, keyHex := range ed25519HexKeys { - privBytes, err := codec.LoadHex(keyHex, ed25519.PrivateKeyLen) - if err != nil { - panic(err) - } - priv := ed25519.PrivateKey(privBytes) - ed25519PrivKeys[i] = priv - ed25519AuthFactories[i] = auth.NewED25519Factory(priv) - addr := auth.NewED25519Address(priv.PublicKey()) - ed25519Addrs[i] = addr - } -} - -type workloadFactory struct { - factories []*auth.ED25519Factory - addrs []codec.Address -} - -func New(minBlockGap int64) (*genesis.DefaultGenesis, workload.TxWorkloadFactory, error) { - customAllocs := make([]*genesis.CustomAllocation, 0, len(ed25519Addrs)) - for _, prefundedAddr := range ed25519Addrs { - customAllocs = append(customAllocs, &genesis.CustomAllocation{ - Address: prefundedAddr, - Balance: initialBalance, - }) - } - - 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} - // Set all limits to MaxUint64 to avoid limiting block size for all dimensions except bandwidth. Must limit bandwidth to avoid building - // a block that exceeds the maximum size allowed by AvalancheGo. - genesis.Rules.MaxBlockUnits = fees.Dimensions{1800000, math.MaxUint64, math.MaxUint64, math.MaxUint64, math.MaxUint64} - genesis.Rules.MinBlockGap = minBlockGap - - return genesis, &workloadFactory{ - factories: ed25519AuthFactories, - addrs: ed25519Addrs, - }, nil -} - -func (f *workloadFactory) NewSizedTxWorkload(uri string, size int) (workload.TxWorkloadIterator, error) { - cli := jsonrpc.NewJSONRPCClient(uri) - lcli := vm.NewJSONRPCClient(uri) - return &simpleTxWorkload{ - factory: f.factories[0], - cli: cli, - lcli: lcli, - size: size, - }, nil -} - -type simpleTxWorkload struct { - factory *auth.ED25519Factory - cli *jsonrpc.JSONRPCClient - lcli *vm.JSONRPCClient - count int - size int -} - -func (g *simpleTxWorkload) Next() bool { - return g.count < g.size -} - -func (g *simpleTxWorkload) GenerateTxWithAssertion(ctx context.Context) (*chain.Transaction, workload.TxAssertion, error) { - g.count++ - other, err := ed25519.GeneratePrivateKey() - if err != nil { - return nil, nil, err - } - - aother := auth.NewED25519Address(other.PublicKey()) - parser, err := g.lcli.Parser(ctx) - if err != nil { - return nil, nil, err - } - _, tx, _, err := g.cli.GenerateTransaction( - ctx, - parser, - []chain.Action{&actions.Transfer{ - To: aother, - Value: 1, - }}, - g.factory, - ) - if err != nil { - return nil, nil, err - } - - return tx, func(ctx context.Context, require *require.Assertions, uri string) { - confirmTx(ctx, require, uri, tx.ID(), aother, 1) - }, nil -} - -func (f *workloadFactory) NewWorkloads(uri string) ([]workload.TxWorkloadIterator, error) { - blsPriv, err := bls.GeneratePrivateKey() - if err != nil { - return nil, err - } - blsPub := bls.PublicFromPrivateKey(blsPriv) - blsAddr := auth.NewBLSAddress(blsPub) - blsFactory := auth.NewBLSFactory(blsPriv) - - secpPriv, err := secp256r1.GeneratePrivateKey() - if err != nil { - return nil, err - } - secpPub := secpPriv.PublicKey() - secpAddr := auth.NewSECP256R1Address(secpPub) - secpFactory := auth.NewSECP256R1Factory(secpPriv) - - cli := jsonrpc.NewJSONRPCClient(uri) - networkID, _, blockchainID, err := cli.Network(context.Background()) - if err != nil { - return nil, err - } - lcli := vm.NewJSONRPCClient(uri) - - generator := &mixedAuthWorkload{ - addressAndFactories: []addressAndFactory{ - {address: f.addrs[1], authFactory: f.factories[1]}, - {address: blsAddr, authFactory: blsFactory}, - {address: secpAddr, authFactory: secpFactory}, - }, - balance: initialBalance, - cli: cli, - lcli: lcli, - networkID: networkID, - chainID: blockchainID, - } - - return []workload.TxWorkloadIterator{generator}, nil -} - -type addressAndFactory struct { - address codec.Address - authFactory chain.AuthFactory -} - -type mixedAuthWorkload struct { - addressAndFactories []addressAndFactory - balance uint64 - cli *jsonrpc.JSONRPCClient - lcli *vm.JSONRPCClient - networkID uint32 - chainID ids.ID - count int -} - -func (g *mixedAuthWorkload) Next() bool { - return g.count < len(g.addressAndFactories)-1 -} - -func (g *mixedAuthWorkload) GenerateTxWithAssertion(ctx context.Context) (*chain.Transaction, workload.TxAssertion, error) { - defer func() { g.count++ }() - - sender := g.addressAndFactories[g.count] - receiver := g.addressAndFactories[g.count+1] - expectedBalance := g.balance - 1_000_000 - - parser, err := g.lcli.Parser(ctx) - if err != nil { - return nil, nil, err - } - _, tx, _, err := g.cli.GenerateTransaction( - ctx, - parser, - []chain.Action{&actions.Transfer{ - To: receiver.address, - Value: expectedBalance, - }}, - sender.authFactory, - ) - if err != nil { - return nil, nil, err - } - g.balance = expectedBalance - - return tx, func(ctx context.Context, require *require.Assertions, uri string) { - confirmTx(ctx, require, uri, tx.ID(), receiver.address, expectedBalance) - }, nil -} - -func confirmTx(ctx context.Context, require *require.Assertions, uri string, txID ids.ID, receiverAddr codec.Address, receiverExpectedBalance uint64) { - indexerCli := indexer.NewClient(uri) - success, _, err := indexerCli.WaitForTransaction(ctx, txCheckInterval, txID) - require.NoError(err) - require.True(success) - lcli := vm.NewJSONRPCClient(uri) - balance, err := lcli.Balance(ctx, receiverAddr) - require.NoError(err) - require.Equal(receiverExpectedBalance, balance) - txRes, _, err := indexerCli.GetTx(ctx, txID) - require.NoError(err) - // TODO: perform exact expected fee, units check, and output check - require.NotZero(txRes.Fee) - require.Len(txRes.Outputs, 1) - transferOutputBytes := []byte(txRes.Outputs[0]) - require.Equal(consts.TransferID, transferOutputBytes[0]) - reader := codec.NewReader(transferOutputBytes, len(transferOutputBytes)) - transferOutputTyped, err := vm.OutputParser.Unmarshal(reader) - require.NoError(err) - transferOutput, ok := transferOutputTyped.(*actions.TransferResult) - require.True(ok) - require.Equal(receiverExpectedBalance, transferOutput.ReceiverBalance) -} 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 ff086c7c8f..05bec79704 100644 --- a/examples/morpheusvm/vm/client.go +++ b/examples/morpheusvm/vm/client.go @@ -13,7 +13,6 @@ import ( "github.com/ava-labs/hypersdk/chain" "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/examples/morpheusvm/consts" - "github.com/ava-labs/hypersdk/examples/morpheusvm/storage" "github.com/ava-labs/hypersdk/genesis" "github.com/ava-labs/hypersdk/requester" "github.com/ava-labs/hypersdk/utils" @@ -106,22 +105,18 @@ func (p *Parser) Rules(_ int64) chain.Rules { return p.genesis.Rules } -func (*Parser) ActionRegistry() chain.ActionRegistry { +func (*Parser) ActionCodec() *codec.TypeParser[chain.Action] { return ActionParser } -func (*Parser) OutputRegistry() chain.OutputRegistry { +func (*Parser) OutputCodec() *codec.TypeParser[codec.Typed] { return OutputParser } -func (*Parser) AuthRegistry() chain.AuthRegistry { +func (*Parser) AuthCodec() *codec.TypeParser[chain.Auth] { return AuthParser } -func (*Parser) StateManager() chain.StateManager { - return &storage.StateManager{} -} - func NewParser(genesis *genesis.DefaultGenesis) chain.Parser { return &Parser{genesis: genesis} } diff --git a/examples/morpheusvm/vm/vm.go b/examples/morpheusvm/vm/vm.go index ee3d56e2b1..6f77d7ba70 100644 --- a/examples/morpheusvm/vm/vm.go +++ b/examples/morpheusvm/vm/vm.go @@ -13,6 +13,7 @@ import ( "github.com/ava-labs/hypersdk/examples/morpheusvm/consts" "github.com/ava-labs/hypersdk/examples/morpheusvm/storage" "github.com/ava-labs/hypersdk/genesis" + "github.com/ava-labs/hypersdk/state/metadata" "github.com/ava-labs/hypersdk/vm" "github.com/ava-labs/hypersdk/vm/defaultvm" ) @@ -33,7 +34,7 @@ func init() { 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), @@ -53,7 +54,8 @@ func New(options ...vm.Option) (*vm.VM, error) { return defaultvm.New( consts.Version, genesis.DefaultGenesisFactory{}, - &storage.StateManager{}, + &storage.BalanceHandler{}, + metadata.NewDefaultManager(), ActionParser, AuthParser, OutputParser, 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/vm/server.go b/examples/vmwithcontracts/vm/server.go deleted file mode 100644 index 60f098d738..0000000000 --- a/examples/vmwithcontracts/vm/server.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. -// See the file LICENSE for licensing terms. - -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" - -var _ api.HandlerFactory[api.VM] = (*jsonRPCServerFactory)(nil) - -type jsonRPCServerFactory struct{} - -func (jsonRPCServerFactory) New(v api.VM) (api.Handler, error) { - handler, err := api.NewJSONRPCHandler(consts.Name, NewJSONRPCServer(v)) - return api.Handler{ - Path: JSONRPCEndpoint, - Handler: handler, - }, err -} - -type JSONRPCServer struct { - vm api.VM -} - -func NewJSONRPCServer(vm api.VM) *JSONRPCServer { - return &JSONRPCServer{vm: vm} -} - -type GenesisReply struct { - Genesis *genesis.DefaultGenesis `json:"genesis"` -} - -func (j *JSONRPCServer) Genesis(_ *http.Request, _ *struct{}, reply *GenesisReply) (err error) { - reply.Genesis = j.vm.Genesis().(*genesis.DefaultGenesis) - return nil -} - -type BalanceArgs struct { - Address codec.Address `json:"address"` -} - -type BalanceReply struct { - Amount uint64 `json:"amount"` -} - -func (j *JSONRPCServer) Balance(req *http.Request, args *BalanceArgs, reply *BalanceReply) error { - ctx, span := j.vm.Tracer().Start(req.Context(), "Server.Balance") - defer span.End() - - balance, err := storage.GetBalanceFromState(ctx, j.vm.ReadState, args.Address) - if err != nil { - return err - } - 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..432ea52696 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/ava-labs/hypersdk -go 1.21.12 +go 1.22.8 require ( github.com/ava-labs/avalanchego v1.11.12-rc.2.0.20241001202925-f03745d187d0 - github.com/bytecodealliance/wasmtime-go/v14 v14.0.0 + github.com/bytecodealliance/wasmtime-go/v25 v25.0.0 github.com/cockroachdb/pebble v0.0.0-20230928194634-aa077af62593 github.com/gorilla/rpc v1.2.0 github.com/gorilla/websocket v1.5.0 @@ -17,6 +17,7 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/prometheus/client_golang v1.16.0 github.com/spf13/cobra v1.5.0 + github.com/spf13/viper v1.12.0 github.com/stretchr/testify v1.8.4 go.opentelemetry.io/otel v1.22.0 go.opentelemetry.io/otel/exporters/zipkin v1.11.2 @@ -27,6 +28,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 @@ -122,7 +124,6 @@ require ( github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/spf13/viper v1.12.0 // indirect github.com/status-im/keycard-go v0.2.0 // indirect github.com/subosito/gotenv v1.3.0 // indirect github.com/supranational/blst v0.3.11 // indirect @@ -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/go.sum b/go.sum index 71faf7f722..20c7b30435 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +91,8 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= -github.com/bytecodealliance/wasmtime-go/v14 v14.0.0 h1:ur7S3P+PAeJmgllhSrKnGQOAmmtUbLQxb/nw2NZiaEM= -github.com/bytecodealliance/wasmtime-go/v14 v14.0.0/go.mod h1:tqOVEUjnXY6aGpSfM9qdVRR6G//Yc513fFYUdzZb/DY= +github.com/bytecodealliance/wasmtime-go/v25 v25.0.0 h1:ZTn4Ho+srrk0466ugqPfTDCITczsWdT48A0ZMA/TpRU= +github.com/bytecodealliance/wasmtime-go/v25 v25.0.0/go.mod h1:8mMIYQ92CpVDwXPIb6udnhtFGI3vDZ/937cGeQr5I68= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= diff --git a/internal/gossiper/dependencies.go b/internal/gossiper/dependencies.go index f9afed5ad1..31fdd1a81b 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,13 +27,12 @@ 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 GetAuthBatchVerifier(authTypeID uint8, cores int, count int) (chain.AuthBatchVerifier, bool) - StateManager() chain.StateManager RecordTxsGossiped(int) RecordSeenTxsReceived(int) 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/scripts/tests.benchmark.sh b/scripts/tests.benchmark.sh index db858136be..6615c38fbf 100755 --- a/scripts/tests.benchmark.sh +++ b/scripts/tests.benchmark.sh @@ -18,6 +18,6 @@ subdir=${1:-.} file_args=() while IFS= read -r line; do file_args+=("$line") -done < <(find "$subdir" -type f -name "*.go" | grep -v "./examples/" | xargs -n1 dirname | sort -u) +done < <(find "$subdir" -type f -name "*.go" | grep -v "./examples/" | grep -v "./x" | xargs -n1 dirname | sort -u) go test -benchmem -run=^$ -timeout="10m" -bench=. -benchtime=1x "${file_args[@]}" diff --git a/scripts/tests.unit.sh b/scripts/tests.unit.sh index bb9e7e7374..d26f17b4af 100755 --- a/scripts/tests.unit.sh +++ b/scripts/tests.unit.sh @@ -18,6 +18,6 @@ subdir=${1:-.} file_args=() while IFS= read -r line; do file_args+=("$line") -done < <(find "$subdir" -type f -name "*.go" | grep -v "./examples/" | xargs -n1 dirname | sort -u) +done < <(find "$subdir" -type f -name "*.go" | grep -v "./examples/" | grep -v "./x" | xargs -n1 dirname | sort -u) go test -race -timeout="6m" -coverprofile="coverage.out" -covermode="atomic" "${file_args[@]}" diff --git a/state/keys.go b/state/keys.go index f57d345aa3..6772a104a0 100644 --- a/state/keys.go +++ b/state/keys.go @@ -3,7 +3,18 @@ package state -import "github.com/ava-labs/hypersdk/keys" +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/keys" +) + +var errInvalidHexadecimalString = errors.New("invalid hexadecimal string") const ( Read Permissions = 1 @@ -50,6 +61,66 @@ func (k Keys) ChunkSizes() ([]uint16, bool) { return chunks, true } +type keysJSON map[string]Permissions + +// MarshalJSON marshals Keys as readable JSON. +// Keys are hex encoded strings and permissions +// are either valid named strings or unknown hex encoded strings. +func (k Keys) MarshalJSON() ([]byte, error) { + kJSON := make(keysJSON) + for key, perm := range k { + hexKey, err := codec.Bytes(key).MarshalText() + if err != nil { + return nil, err + } + kJSON[string(hexKey)] = perm + } + return json.Marshal(kJSON) +} + +// UnmarshalJSON unmarshals readable JSON. +func (k *Keys) UnmarshalJSON(b []byte) error { + var keysJSON keysJSON + if err := json.Unmarshal(b, &keysJSON); err != nil { + return err + } + for hexKey, perm := range keysJSON { + var key codec.Bytes + err := key.UnmarshalText([]byte(hexKey)) + if err != nil { + return err + } + (*k)[string(key)] = perm + } + return nil +} + +func (p *Permissions) UnmarshalText(in []byte) error { + switch str := strings.ToLower(string(in)); str { + case "read": + *p = Read + case "write": + *p = Write + case "allocate": + *p = Allocate + case "all": + *p = All + case "none": + *p = None + default: + res, err := hex.DecodeString(str) + if err != nil || len(res) != 1 { + return fmt.Errorf("permission %s: %w", str, errInvalidHexadecimalString) + } + *p = Permissions(res[0]) + } + return nil +} + +func (p Permissions) MarshalText() ([]byte, error) { + return []byte(p.String()), 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 @@ -68,6 +139,6 @@ func (p Permissions) String() string { case None: return "none" default: - return "unknown" + return hex.EncodeToString([]byte{byte(p)}) } } diff --git a/state/keys_test.go b/state/keys_test.go index 27733dd777..16094bceff 100644 --- a/state/keys_test.go +++ b/state/keys_test.go @@ -4,7 +4,11 @@ package state import ( + "crypto/sha256" + "encoding/binary" + "math/rand" "slices" + "strconv" "testing" "github.com/stretchr/testify/require" @@ -123,3 +127,119 @@ 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(`{"6b657931":"read"}`, string(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(`{"6b657932":"write"}`, string(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)) + } +} + +func TestNewPermissionFromString(t *testing.T) { + tests := []struct { + strPerm string + perm Permissions + expectedErr error + }{ + { + strPerm: "read", + perm: Read, + }, + { + strPerm: "write", + perm: Write, + }, + { + strPerm: "allocate", + perm: Allocate, + }, + { + strPerm: "all", + perm: All, + }, + { + strPerm: "none", + perm: None, + }, + { + strPerm: "09", + perm: Permissions(9), + }, + { + strPerm: "010A", + expectedErr: errInvalidHexadecimalString, + }, + } + + for i, test := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + require := require.New(t) + var perm Permissions + err := perm.UnmarshalText([]byte(test.strPerm)) + if test.expectedErr != nil { + require.ErrorIs(err, test.expectedErr) + } else { + require.NoError(err) + require.Equal(test.perm, perm) + } + }) + } +} + +func TestPermissionStringer(t *testing.T) { + require := require.New(t) + require.Equal("read", Read.String()) + require.Equal("write", Write.String()) + require.Equal("allocate", Allocate.String()) + require.Equal("all", All.String()) + require.Equal("none", None.String()) + require.Equal("09", Permissions(9).String()) +} diff --git a/state/metadata/state_manager.go b/state/metadata/state_manager.go new file mode 100644 index 0000000000..408942ec84 --- /dev/null +++ b/state/metadata/state_manager.go @@ -0,0 +1,85 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "bytes" + + "github.com/ava-labs/hypersdk/chain" +) + +const ( + defaultHeightPrefix byte = 0x0 + defaultTimestampPrefix byte = 0x1 + defaultFeePrefix byte = 0x2 + + DefaultMinimumPrefix byte = 0x3 +) + +var _ chain.MetadataManager = (*MetadataManager)(nil) + +type MetadataManager struct { + heightPrefix []byte + feePrefix []byte + timestampPrefix []byte +} + +func NewManager( + heightPrefix []byte, + feePrefix []byte, + timestampPrefix []byte, +) MetadataManager { + return MetadataManager{ + heightPrefix: heightPrefix, + feePrefix: feePrefix, + timestampPrefix: timestampPrefix, + } +} + +func NewDefaultManager() MetadataManager { + return MetadataManager{ + heightPrefix: []byte{defaultHeightPrefix}, + feePrefix: []byte{defaultFeePrefix}, + timestampPrefix: []byte{defaultTimestampPrefix}, + } +} + +func (m MetadataManager) FeePrefix() []byte { + return m.feePrefix +} + +func (m MetadataManager) HeightPrefix() []byte { + return m.heightPrefix +} + +func (m MetadataManager) TimestampPrefix() []byte { + return m.timestampPrefix +} + +// Returns true if all prefixes in `m` and `vmPrefixes` are unique +func HasConflictingPrefixes( + m chain.MetadataManager, + vmPrefixes [][]byte, +) bool { + prefixes := [][]byte{ + m.HeightPrefix(), + m.FeePrefix(), + m.TimestampPrefix(), + } + + prefixes = append(prefixes, vmPrefixes...) + verifiedPrefixes := make([][]byte, 0) + + for _, p := range prefixes { + for _, vp := range verifiedPrefixes { + if bytes.HasPrefix(p, vp) || bytes.HasPrefix(vp, p) { + return true + } + } + + verifiedPrefixes = append(verifiedPrefixes, p) + } + + return false +} diff --git a/state/metadata/state_manager_test.go b/state/metadata/state_manager_test.go new file mode 100644 index 0000000000..961148e8e7 --- /dev/null +++ b/state/metadata/state_manager_test.go @@ -0,0 +1,81 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package metadata + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConflictingPrefixes(t *testing.T) { + metadataManager := NewDefaultManager() + + tests := []struct { + name string + metadataManager MetadataManager + vmPrefixes [][]byte + hasConflict bool + }{ + { + name: "no conflicts", + metadataManager: metadataManager, + vmPrefixes: [][]byte{ + {DefaultMinimumPrefix}, + }, + }, + { + name: "identical prefixes", + metadataManager: metadataManager, + vmPrefixes: [][]byte{ + metadataManager.HeightPrefix(), + }, + hasConflict: true, + }, + { + name: "metadataManager prefixes contains a prefix of one of the vm prefixes", + metadataManager: metadataManager, + vmPrefixes: [][]byte{ + {0x1, 0x1}, + }, + hasConflict: true, + }, + { + name: "vmPrefix contains a prefix of one of metadataManager prefixes", + metadataManager: func() MetadataManager { + return NewManager( + []byte{0x0, 0x1}, + []byte{0x1, 0x1}, + []byte{0x2, 0x1}, + ) + }(), + vmPrefixes: [][]byte{ + {0x1}, + }, + hasConflict: true, + }, + { + name: "vmPrefixes contains a duplicate", + metadataManager: metadataManager, + vmPrefixes: [][]byte{ + {DefaultMinimumPrefix}, + {DefaultMinimumPrefix}, + }, + hasConflict: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + require.Equal( + tt.hasConflict, + HasConflictingPrefixes( + tt.metadataManager, + tt.vmPrefixes, + ), + ) + }) + } +} 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..7a96c5b8e5 100644 --- a/tests/e2e/e2e.go +++ b/tests/e2e/e2e.go @@ -18,25 +18,31 @@ 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/chain" + "github.com/ava-labs/hypersdk/auth" + "github.com/ava-labs/hypersdk/tests/registry" "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" ) var ( - vmName string - txWorkloadFactory workload.TxWorkloadFactory - parser chain.Parser - expectedABI abi.ABI + networkConfig workload.TestNetworkConfiguration + txWorkload workload.TxWorkload + expectedABI abi.ABI + spamKey *auth.PrivateKey + spamHelper throughput.SpamHelper ) -func SetWorkload(name string, factory workload.TxWorkloadFactory, chainParser chain.Parser, abi abi.ABI) { - vmName = name - txWorkloadFactory = factory - parser = chainParser +func SetWorkload(networkConfigImpl workload.TestNetworkConfiguration, generator workload.TxGenerator, abi abi.ABI, sh throughput.SpamHelper, key *auth.PrivateKey) { + networkConfig = networkConfigImpl + txWorkload = workload.TxWorkload{ + Generator: generator, + } expectedABI = abi + spamHelper = sh + spamKey = key } var _ = ginkgo.Describe("[HyperSDK APIs]", func() { @@ -44,23 +50,23 @@ var _ = ginkgo.Describe("[HyperSDK APIs]", func() { require := require.New(tc) ginkgo.It("Ping", func() { - expectedBlockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(vmName).Chains[0].ChainID + expectedBlockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(networkConfig.Name()).Chains[0].ChainID workload.Ping(tc.DefaultContext(), require, getE2EURIs(tc, expectedBlockchainID)) }) ginkgo.It("StableNetworkIdentity", func() { hardcodedHostPort := "http://localhost:9650" - fixedNodeURL := hardcodedHostPort + "/ext/bc/" + vmName + fixedNodeURL := hardcodedHostPort + "/ext/bc/" + networkConfig.Name() c := jsonrpc.NewJSONRPCClient(fixedNodeURL) _, _, chainIDFromRPC, err := c.Network(tc.DefaultContext()) require.NoError(err) - expectedBlockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(vmName).Chains[0].ChainID + expectedBlockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(networkConfig.Name()).Chains[0].ChainID require.Equal(expectedBlockchainID, chainIDFromRPC) }) ginkgo.It("GetNetwork", func() { - expectedBlockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(vmName).Chains[0].ChainID + expectedBlockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(networkConfig.Name()).Chains[0].ChainID baseURIs := getE2EBaseURIs(tc) baseURI := baseURIs[0] client := info.NewClient(baseURI) @@ -70,12 +76,12 @@ var _ = ginkgo.Describe("[HyperSDK APIs]", func() { }) ginkgo.It("GetABI", func() { - expectedBlockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(vmName).Chains[0].ChainID + expectedBlockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(networkConfig.Name()).Chains[0].ChainID workload.GetABI(tc.DefaultContext(), require, getE2EURIs(tc, expectedBlockchainID), expectedABI) }) ginkgo.It("ReadState", func() { - blockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(vmName).Chains[0].ChainID + blockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(networkConfig.Name()).Chains[0].ChainID ctx := tc.DefaultContext() for _, uri := range getE2EURIs(tc, blockchainID) { client := state.NewJSONRPCStateClient(uri) @@ -89,35 +95,55 @@ var _ = ginkgo.Describe("[HyperSDK APIs]", func() { }) }) -var _ = ginkgo.Describe("[HyperSDK Tx Workloads]", func() { +var _ = ginkgo.Describe("[HyperSDK Tx Workloads]", ginkgo.Serial, func() { ginkgo.It("Basic Tx Workload", func() { tc := e2e.NewTestContext() require := require.New(tc) - blockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(vmName).Chains[0].ChainID + blockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(networkConfig.Name()).Chains[0].ChainID ginkgo.By("Tx workloads", func() { - txWorkloads, err := txWorkloadFactory.NewWorkloads(getE2EURIs(tc, blockchainID)[0]) - require.NoError(err) - for _, txWorkload := range txWorkloads { - workload.ExecuteWorkload(tc.DefaultContext(), require, getE2EURIs(tc, blockchainID), txWorkload) - } + txWorkload.GenerateBlocks(tc.DefaultContext(), require, getE2EURIs(tc, blockchainID), 1) }) ginkgo.By("Confirm accepted blocks indexed", func() { - workload.GetBlocks(tc.DefaultContext(), require, parser, getE2EURIs(tc, blockchainID)) + workload.GetBlocks(tc.DefaultContext(), require, networkConfig.Parser(), getE2EURIs(tc, blockchainID)) }) }) }) -var _ = ginkgo.Describe("[HyperSDK Syncing]", func() { +var _ = ginkgo.Describe("[HyperSDK Spam Workloads]", ginkgo.Serial, 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(networkConfig.Name()).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]", ginkgo.Serial, func() { ginkgo.It("[Sync]", func() { tc := e2e.NewTestContext() require := require.New(tc) - blockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(vmName).Chains[0].ChainID + blockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(networkConfig.Name()).Chains[0].ChainID uris := getE2EURIs(tc, blockchainID) ginkgo.By("Generate 128 blocks", func() { - workload.GenerateNBlocks(tc.ContextWithTimeout(5*time.Minute), require, uris, txWorkloadFactory, 128) + txWorkload.GenerateBlocks(tc.ContextWithTimeout(5*time.Minute), require, uris, 128) }) var ( @@ -130,15 +156,14 @@ var _ = ginkgo.Describe("[HyperSDK Syncing]", func() { uris = append(uris, bootstrapNodeURI) }) ginkgo.By("Accept a transaction after state sync", func() { - txWorkload, err := txWorkloadFactory.NewSizedTxWorkload(bootstrapNodeURI, 1) - require.NoError(err) - workload.ExecuteWorkload(tc.DefaultContext(), require, uris, txWorkload) + txWorkload.GenerateTxs(tc.DefaultContext(), require, 1, bootstrapNodeURI, uris) }) + ginkgo.By("Restart the node", func() { require.NoError(e2e.GetEnv(tc).GetNetwork().RestartNode(tc.DefaultContext(), ginkgo.GinkgoWriter, bootstrapNode)) }) ginkgo.By("Generate > StateSyncMinBlocks=512", func() { - workload.GenerateNBlocks(tc.ContextWithTimeout(20*time.Minute), require, uris, txWorkloadFactory, 512) + txWorkload.GenerateBlocks(tc.ContextWithTimeout(20*time.Minute), require, uris, 512) }) var ( syncNode *tmpnet.Node @@ -154,9 +179,7 @@ var _ = ginkgo.Describe("[HyperSDK Syncing]", func() { require.NoError(err) }) ginkgo.By("Accept a transaction after state sync", func() { - txWorkload, err := txWorkloadFactory.NewSizedTxWorkload(syncNodeURI, 1) - require.NoError(err) - workload.ExecuteWorkload(tc.DefaultContext(), require, uris, txWorkload) + txWorkload.GenerateTxs(tc.DefaultContext(), require, 1, syncNodeURI, uris) }) ginkgo.By("Pause the node", func() { // TODO: remove the need to call SaveAPIPort from the test @@ -172,7 +195,7 @@ var _ = ginkgo.Describe("[HyperSDK Syncing]", func() { ginkgo.By("Generate 256 blocks", func() { // Generate blocks on all nodes except the paused node runningURIs := uris[:len(uris)-1] - workload.GenerateNBlocks(tc.ContextWithTimeout(5*time.Minute), require, runningURIs, txWorkloadFactory, 256) + txWorkload.GenerateBlocks(tc.ContextWithTimeout(5*time.Minute), require, runningURIs, 256) }) ginkgo.By("Resume the node", func() { require.NoError(e2e.GetEnv(tc).GetNetwork().StartNode(tc.DefaultContext(), ginkgo.GinkgoWriter, syncNode)) @@ -187,9 +210,7 @@ var _ = ginkgo.Describe("[HyperSDK Syncing]", func() { }) ginkgo.By("Accept a transaction after resuming", func() { - txWorkload, err := txWorkloadFactory.NewSizedTxWorkload(syncNodeURI, 1) - require.NoError(err) - workload.ExecuteWorkload(tc.DefaultContext(), require, uris, txWorkload) + txWorkload.GenerateTxs(tc.DefaultContext(), require, 1, syncNodeURI, uris) }) ginkgo.By("State sync while broadcasting txs", func() { stopChannel := make(chan struct{}) @@ -202,10 +223,7 @@ var _ = ginkgo.Describe("[HyperSDK Syncing]", func() { defer wg.Done() // Recover failure if exits defer ginkgo.GinkgoRecover() - - txWorkload, err := txWorkloadFactory.NewSizedTxWorkload(uris[0], 128) - require.NoError(err) - workload.GenerateUntilStop(tc.DefaultContext(), require, uris, txWorkload, stopChannel) + txWorkload.GenerateUntilStop(tc.DefaultContext(), require, uris, 128, stopChannel) }() // Give time for transactions to start processing @@ -219,13 +237,24 @@ var _ = ginkgo.Describe("[HyperSDK Syncing]", func() { require.NoError(err) }) ginkgo.By("Accept a transaction after syncing", func() { - txWorkload, err := txWorkloadFactory.NewSizedTxWorkload(uris[0], 1) - require.NoError(err) - workload.ExecuteWorkload(tc.DefaultContext(), require, uris, txWorkload) + txWorkload.GenerateTxs(tc.DefaultContext(), require, 1, uris[0], uris) }) }) }) +var _ = ginkgo.Describe("[Custom VM Tests]", ginkgo.Serial, func() { + tc := e2e.NewTestContext() + + for testRegistry := range registry.GetTestsRegistries() { + for _, test := range testRegistry.List() { + ginkgo.It(test.Name, func() { + testNetwork := NewNetwork(tc) + test.Fnc(ginkgo.GinkgoT(), testNetwork) + }) + } + } +}) + func getE2EURIs(tc tests.TestContext, blockchainID ids.ID) []string { nodeURIs := e2e.GetEnv(tc).GetNetwork().GetNodeURIs() uris := make([]string, 0, len(nodeURIs)) diff --git a/tests/e2e/network.go b/tests/e2e/network.go new file mode 100644 index 0000000000..4e79f553cb --- /dev/null +++ b/tests/e2e/network.go @@ -0,0 +1,129 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package e2e + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/tests/fixture/e2e" + + "github.com/ava-labs/hypersdk/api/indexer" + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/tests/workload" +) + +var ( + ErrUnableToConfirmTx = errors.New("unable to confirm transaction") + ErrInvalidURI = errors.New("invalid uri") +) + +const ( + txCheckInterval = 100 * time.Millisecond +) + +type Network struct { + uris []string + // The parser here is the original parser provided by the vm, with the chain ID populated by + // the newly created network. On e2e networks, we can't tell in advance what the ChainID would be, + // and therefore need to update it from the network. + parser *parser +} + +func NewNetwork(tc *e2e.GinkgoTestContext) *Network { + blockchainID := e2e.GetEnv(tc).GetNetwork().GetSubnet(networkConfig.Name()).Chains[0].ChainID + testNetwork := &Network{ + uris: getE2EURIs(tc, blockchainID), + parser: &parser{ + Parser: networkConfig.Parser(), + rules: &rules{ + Rules: networkConfig.Parser().Rules(0), + chainID: blockchainID, + }, + }, + } + return testNetwork +} + +func (n *Network) URIs() []string { + return n.uris +} + +func (n *Network) ConfirmTxs(ctx context.Context, txs []*chain.Transaction) error { + c := jsonrpc.NewJSONRPCClient(n.uris[0]) + txIDs := []ids.ID{} + for _, tx := range txs { + txID, err := c.SubmitTx(ctx, tx.Bytes()) + if err != nil { + return fmt.Errorf("unable to submit transaction : %w", err) + } + txIDs = append(txIDs, txID) + } + + indexerCli := indexer.NewClient(n.uris[0]) + for _, txID := range txIDs { + success, _, err := indexerCli.WaitForTransaction(ctx, txCheckInterval, txID) + if err != nil { + return fmt.Errorf("error while waiting for transaction : %w", err) + } + if !success { + return ErrUnableToConfirmTx + } + } + + _, targetHeight, _, err := c.Accepted(ctx) + if err != nil { + return err + } + for _, uri := range n.uris[1:] { + if err := jsonrpc.Wait(ctx, txCheckInterval, func(ctx context.Context) (bool, error) { + c := jsonrpc.NewJSONRPCClient(uri) + _, nodeHeight, _, err := c.Accepted(ctx) + if err != nil { + return false, err + } + return nodeHeight >= targetHeight, nil + }); err != nil { + return err + } + } + return nil +} + +func (n *Network) GenerateTx(ctx context.Context, actions []chain.Action, auth chain.AuthFactory) (*chain.Transaction, error) { + c := jsonrpc.NewJSONRPCClient(n.uris[0]) + _, tx, _, err := c.GenerateTransaction( + ctx, + n.parser, + actions, + auth, + ) + return tx, err +} + +func (*Network) Configuration() workload.TestNetworkConfiguration { + return networkConfig +} + +type rules struct { + chain.Rules + chainID ids.ID +} + +func (r *rules) GetChainID() ids.ID { + return r.chainID +} + +type parser struct { + chain.Parser + rules *rules +} + +func (p *parser) Rules(int64) chain.Rules { + return p.rules +} diff --git a/tests/fixture/environment.go b/tests/fixture/environment.go index c8db18eb11..5ae56de093 100644 --- a/tests/fixture/environment.go +++ b/tests/fixture/environment.go @@ -13,6 +13,8 @@ import ( "github.com/ava-labs/avalanchego/tests/fixture/e2e" "github.com/ava-labs/avalanchego/tests/fixture/tmpnet" "github.com/stretchr/testify/require" + + "github.com/ava-labs/hypersdk/tests/workload" ) var StableNodeURI = fmt.Sprintf("http://localhost:%d", config.DefaultHTTPPort) @@ -21,18 +23,17 @@ func NewTestEnvironment( testContext tests.TestContext, flagVars *e2e.FlagVars, owner string, - vmName string, + networkConfig workload.TestNetworkConfiguration, vmID ids.ID, - genesisBytes []byte, ) *e2e.TestEnvironment { // Run only once in the first ginkgo process nodes := tmpnet.NewNodesOrPanic(flagVars.NodeCount()) nodes[0].Flags[config.HTTPPortKey] = config.DefaultHTTPPort subnet := NewHyperVMSubnet( - vmName, + networkConfig.Name(), vmID, - genesisBytes, + networkConfig.GenesisBytes(), nodes..., ) network := NewTmpnetNetwork(owner, nodes, subnet) @@ -43,8 +44,8 @@ func NewTestEnvironment( network, ) - chainID := testEnv.GetNetwork().GetSubnet(vmName).Chains[0].ChainID - setupDefaultChainAlias(testContext, chainID, vmName) + chainID := testEnv.GetNetwork().GetSubnet(networkConfig.Name()).Chains[0].ChainID + setupDefaultChainAlias(testContext, chainID, networkConfig.Name()) return testEnv } diff --git a/tests/integration/integration.go b/tests/integration/integration.go index b5d787e33e..c152a66a26 100644 --- a/tests/integration/integration.go +++ b/tests/integration/integration.go @@ -6,6 +6,7 @@ package integration import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -35,6 +36,7 @@ import ( "github.com/ava-labs/hypersdk/extension/externalsubscriber" "github.com/ava-labs/hypersdk/fees" "github.com/ava-labs/hypersdk/pubsub" + "github.com/ava-labs/hypersdk/tests/registry" "github.com/ava-labs/hypersdk/tests/workload" "github.com/ava-labs/hypersdk/vm" @@ -54,21 +56,21 @@ var ( log logging.Logger // when used with embedded VMs - instances []instance + instances []*instance sendAppGossipCounter int uris []string blocks []snowman.Block + testNetwork *Network networkID uint32 // Injected values populated by Setup - createVM func(...vm.Option) (*vm.VM, error) - genesisBytes []byte - vmID ids.ID - createParserFromBytes func(genesisBytes []byte) (chain.Parser, error) - parser chain.Parser - txWorkloadFactory workload.TxWorkloadFactory - authFactory chain.AuthFactory + createVM func(...vm.Option) (*vm.VM, error) + networkConfig workload.TestNetworkConfiguration + vmID ids.ID + + txWorkload workload.TxWorkload + authFactory chain.AuthFactory externalSubscriberAcceptedBlocksCh chan ids.ID ) @@ -98,24 +100,19 @@ func init() { func Setup( newVM func(...vm.Option) (*vm.VM, error), - genesis []byte, + networkConfigImpl workload.TestNetworkConfiguration, id ids.ID, - createParser func(genesisBytes []byte) (chain.Parser, error), - workloadFactory workload.TxWorkloadFactory, + generator workload.TxGenerator, authF chain.AuthFactory, ) { - require := require.New(ginkgo.GinkgoT()) createVM = newVM - genesisBytes = genesis + networkConfig = networkConfigImpl vmID = id - createParserFromBytes = createParser - txWorkloadFactory = workloadFactory + txWorkload = workload.TxWorkload{ + Generator: generator, + } authFactory = authF - createdParser, err := createParserFromBytes(genesisBytes) - require.NoError(err) - parser = createdParser - setInstances() } @@ -125,7 +122,11 @@ func setInstances() { log.Info("VMID", zap.Stringer("id", vmID)) // create embedded VMs - instances = make([]instance, numVMs) + instances = make([]*instance, numVMs) + + createParserFromBytes := func(_ []byte) (chain.Parser, error) { + return networkConfig.Parser(), nil + } externalSubscriberAcceptedBlocksCh = make(chan ids.ID, 1) externalSubscriber0 := externalsubscriber.NewExternalSubscriberServer(log, createParserFromBytes, []event.Subscription[*chain.ExecutedBlock]{ @@ -169,9 +170,9 @@ func setInstances() { configs := make([][]byte, numVMs) configs[0] = externalSubscriberConfigBytes - networkID = uint32(1) + networkID = networkConfig.Parser().Rules(0).GetNetworkID() subnetID := ids.GenerateTestID() - chainID := ids.GenerateTestID() + chainID := networkConfig.Parser().Rules(0).GetChainID() app := &enginetest.Sender{ SendAppGossipF: func(ctx context.Context, _ common.SendConfig, appGossipBytes []byte) error { @@ -216,7 +217,7 @@ func setInstances() { context.TODO(), snowCtx, db, - genesisBytes, + networkConfig.GenesisBytes(), nil, configs[i], toEngine, @@ -235,7 +236,7 @@ func setInstances() { routerServer := httptest.NewServer(router) jsonRPCServer := httptest.NewServer(hd[jsonrpc.Endpoint]) webSocketServer := httptest.NewServer(hd[ws.Endpoint]) - instances[i] = instance{ + instances[i] = &instance{ chainID: snowCtx.ChainID, nodeID: snowCtx.NodeID, vm: v, @@ -264,6 +265,7 @@ func setInstances() { for i, inst := range instances { uris[i] = inst.routerServer.URL } + testNetwork = &Network{uris: uris} blocks = []snowman.Block{} @@ -296,8 +298,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) @@ -322,12 +324,12 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { ) ginkgo.It("Gossip TransferTx to a different node", func() { uri := uris[0] - workload, err := txWorkloadFactory.NewSizedTxWorkload(uri, 100) - require.NoError(err) ginkgo.By("issue TransferTx", func() { - initialTx, initialTxAssertion, err = workload.GenerateTxWithAssertion(ctx) + tx, assertion, err := txWorkload.Generator.GenerateTx(ctx, uri) + initialTxAssertion = assertion + initialTx = tx require.NoError(err) - _, err := instances[0].cli.SubmitTx(ctx, initialTx.Bytes()) + _, err = instances[0].cli.SubmitTx(ctx, initialTx.Bytes()) require.NoError(err) require.Equal(1, instances[0].vm.Mempool().Len(context.Background())) @@ -346,7 +348,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 +362,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(), @@ -413,10 +418,9 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { }) ginkgo.It("ensure multiple txs work ", func() { - workload, err := txWorkloadFactory.NewSizedTxWorkload(uris[0], 100) - require.NoError(err) ginkgo.By("transfer funds again", func() { - tx, txAssertion, err := workload.GenerateTxWithAssertion(ctx) + tx, txAssertion, err := txWorkload.Generator.GenerateTx(ctx, uris[0]) + require.NoError(err) _, err = instances[1].cli.SubmitTx(ctx, tx.Bytes()) require.NoError(err) @@ -432,7 +436,8 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { ginkgo.By("transfer funds again (test storage keys)", func() { for i := 0; i < 4; i++ { - tx, _, err := workload.GenerateTxWithAssertion(ctx) + tx, _, err := txWorkload.Generator.GenerateTx(ctx, uris[0]) + require.NoError(err) _, err = instances[1].cli.SubmitTx(ctx, tx.Bytes()) require.NoError(err) @@ -456,18 +461,15 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { ginkgo.It("Test processing block handling", func() { var accept, accept2 func(bool) []*chain.Result - - txWorkload, err := txWorkloadFactory.NewSizedTxWorkload(uris[0], 100) - require.NoError(err) ginkgo.By("create processing tip", func() { - tx, _, err := txWorkload.GenerateTxWithAssertion(ctx) + tx, _, err := txWorkload.Generator.GenerateTx(ctx, uris[0]) require.NoError(err) _, err = instances[1].cli.SubmitTx(ctx, tx.Bytes()) require.NoError(err) accept = expectBlk(instances[1]) - tx, _, err = txWorkload.GenerateTxWithAssertion(ctx) + tx, _, err = txWorkload.Generator.GenerateTx(ctx, uris[0]) require.NoError(err) _, err = instances[1].cli.SubmitTx(ctx, tx.Bytes()) require.NoError(err) @@ -484,15 +486,13 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { }) ginkgo.By("Confirm accepted blocks indexed", func() { - workload.GetBlocks(ctx, require, parser, []string{uris[1]}) + workload.GetBlocks(ctx, require, networkConfig.Parser(), []string{uris[1]}) }) }) ginkgo.It("ensure mempool works", func() { - workload, err := txWorkloadFactory.NewSizedTxWorkload(uris[0], 100) - require.NoError(err) ginkgo.By("fail Gossip TransferTx to a stale node when missing previous blocks", func() { - tx, _, err := workload.GenerateTxWithAssertion(ctx) + tx, _, err := txWorkload.Generator.GenerateTx(ctx, uris[0]) require.NoError(err) _, err = instances[1].cli.SubmitTx(ctx, tx.Bytes()) @@ -540,9 +540,11 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { }) ginkgo.It("processes valid index transactions (w/block listening)", func() { - // Clear previous txs on instance 0 - accept := expectBlk(instances[0]) - accept(false) // don't care about results + // synchronize the nodes on the network. + // this would clear previous txs on instance 0 + // and ignore the transaction results while + // syncronizing the nodes on the network. + require.NoError(testNetwork.SynchronizeNetwork(context.Background())) // Subscribe to blocks cli, err := ws.NewWebSocketClient(instances[0].WebSocketServer.URL, ws.DefaultHandshakeTimeout, pubsub.MaxPendingMessages, pubsub.MaxReadMessageSize) @@ -552,15 +554,13 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { // Wait for message to be sent time.Sleep(2 * pubsub.MaxMessageWait) // TODO: remove after websocket server rewrite - workload, err := txWorkloadFactory.NewSizedTxWorkload(uris[0], 100) - require.NoError(err) - tx, txAssertion, err := workload.GenerateTxWithAssertion(ctx) + tx, txAssertion, err := txWorkload.Generator.GenerateTx(ctx, uris[0]) require.NoError(err) _, err = instances[0].cli.SubmitTx(ctx, tx.Bytes()) require.NoError(err) - accept = expectBlk(instances[0]) + accept := expectBlk(instances[0]) results := accept(false) require.Len(results, 1) require.True(results[0].Success) @@ -570,7 +570,7 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { txAssertion(cctx, require, uris[0]) // Read item from connection - blk, lresults, prices, err := cli.ListenBlock(context.TODO(), parser) + blk, lresults, prices, err := cli.ListenBlock(context.TODO(), networkConfig.Parser()) require.NoError(err) require.Len(blk.Txs, 1) require.Equal(lresults, results) @@ -586,9 +586,7 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { require.NoError(err) // Create tx - workload, err := txWorkloadFactory.NewSizedTxWorkload(uris[0], 100) - require.NoError(err) - tx, txAssertion, err := workload.GenerateTxWithAssertion(ctx) + tx, txAssertion, err := txWorkload.Generator.GenerateTx(ctx, uris[0]) require.NoError(err) // Submit tx and accept block @@ -624,28 +622,17 @@ var _ = ginkgo.Describe("[Tx Processing]", ginkgo.Serial, func() { require.NoError(cli.Close()) }) - ginkgo.It("Workloads", func() { - workloads, err := txWorkloadFactory.NewWorkloads(uris[0]) - require.NoError(err) - - for _, txGenerator := range workloads { - for txGenerator.Next() { - tx, txAssertion, err := txGenerator.GenerateTxWithAssertion(ctx) - require.NoError(err) - _, err = instances[0].cli.SubmitTx(ctx, tx.Bytes()) - require.NoError(err) - - accept := expectBlk(instances[0]) - _ = accept(true) - cctx, cancel := context.WithTimeout(ctx, 1*time.Second) - defer cancel() - txAssertion(cctx, require, uris[0]) - } + for testRegistry := range registry.GetTestsRegistries() { + for _, test := range testRegistry.List() { + ginkgo.It(fmt.Sprintf("Custom VM Test '%s'", test.Name), func() { + require.NoError(testNetwork.SynchronizeNetwork(context.Background())) + test.Fnc(ginkgo.GinkgoT(), testNetwork) + }) } - }) + } }) -func expectBlk(i instance) func(add bool) []*chain.Result { +func expectBlk(i *instance) func(add bool) []*chain.Result { require := require.New(ginkgo.GinkgoT()) ctx := context.TODO() diff --git a/tests/integration/network.go b/tests/integration/network.go new file mode 100644 index 0000000000..e16db47bfa --- /dev/null +++ b/tests/integration/network.go @@ -0,0 +1,184 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package integration + +import ( + "context" + "errors" + "fmt" + + "github.com/ava-labs/avalanchego/ids" + + "github.com/ava-labs/hypersdk/api/jsonrpc" + "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/tests/workload" +) + +var ( + ErrUnableToConfirmTx = errors.New("unable to confirm transaction") + ErrInvalidURI = errors.New("invalid uri") + ErrTxNotFound = errors.New("tx not found") +) + +type Network struct { + uris []string +} + +func (*Network) ConfirmTxs(ctx context.Context, txs []*chain.Transaction) error { + err := instances[0].confirmTxs(ctx, txs) + if err != nil { + return err + } + lastAcceptedBlock := instances[0].vm.LastAcceptedBlock() + for i := 1; i < len(instances); i++ { + err = instances[i].applyBlk(ctx, lastAcceptedBlock) + if err != nil { + return err + } + } + return nil +} + +func (*Network) GenerateTx(ctx context.Context, actions []chain.Action, auth chain.AuthFactory) (*chain.Transaction, error) { + return instances[0].GenerateTx(ctx, actions, auth) +} + +// SynchronizeNetwork ensures that all the nodes on the network are at the same block height. +// this method should be called at the beginning of each test to ensure good starting point. +func (*Network) SynchronizeNetwork(ctx context.Context) error { + // find the latest block height across the network + var biggestHeight uint64 + var biggestHeightInstanceIndex int + for i, instance := range instances { + lastHeight, err := instance.vm.GetLastAcceptedHeight() + if err != nil { + return err + } + if lastHeight >= biggestHeight { + biggestHeightInstanceIndex = i + biggestHeight = lastHeight + } + } + for i := 0; i < len(instances); i++ { + if i == biggestHeightInstanceIndex { + continue + } + instance := instances[i] + for { + height, err := instance.vm.GetLastAcceptedHeight() + if err != nil { + return err + } + if height == biggestHeight { + break + } + statefulBlock, err := instances[biggestHeightInstanceIndex].vm.GetDiskBlock(ctx, height+1) + if err != nil { + return err + } + if err := instance.applyBlk(ctx, statefulBlock); err != nil { + return err + } + } + } + return nil +} + +func (i *instance) applyBlk(ctx context.Context, lastAcceptedBlock *chain.StatefulBlock) error { + err := i.vm.SetPreference(ctx, lastAcceptedBlock.ID()) + if err != nil { + return fmt.Errorf("applyBlk failed to set preference : %w", err) + } + blk, err := i.vm.ParseBlock(ctx, lastAcceptedBlock.Bytes()) + if err != nil { + return fmt.Errorf("applyBlk failed to parse block : %w", err) + } + err = blk.Verify(ctx) + if err != nil { + return fmt.Errorf("applyBlk failed to verify block : %w", err) + } + err = blk.Accept(ctx) + if err != nil { + return fmt.Errorf("applyBlk failed to accept block : %w", err) + } + if i.onAccept != nil { + i.onAccept(blk) + } + return nil +} + +func (i *instance) confirmTxs(ctx context.Context, txs []*chain.Transaction) error { + errs := i.vm.Submit(ctx, true, txs) + if len(errs) != 0 && errs[0] != nil { + return errs[0] + } + + expectBlk(i)(false) + + for _, tx := range txs { + err := i.confirmTx(ctx, tx.ID()) + if err != nil { + return err + } + } + + return nil +} + +func (i *instance) URI() string { + return i.routerServer.URL +} + +func (i *instance) GenerateTx(ctx context.Context, actions []chain.Action, auth chain.AuthFactory) (*chain.Transaction, error) { + // TODO: support generating tx without using jsonRPC client + c := jsonrpc.NewJSONRPCClient(i.URI()) + _, tx, _, err := c.GenerateTransaction( + ctx, + networkConfig.Parser(), + actions, + auth, + ) + return tx, err +} + +func (i *instance) confirmTx(ctx context.Context, txid ids.ID) error { + lastAcceptedHeight, err := i.vm.GetLastAcceptedHeight() + if err != nil { + return err + } + lastAcceptedBlockID, err := i.vm.GetBlockHeightID(lastAcceptedHeight) + if err != nil { + return err + } + blk, err := i.vm.GetBlock(ctx, lastAcceptedBlockID) + if err != nil { + return err + } + for { + stflBlk, ok := blk.(*chain.StatefulBlock) + if !ok { + return ErrTxNotFound + } + for _, tx := range stflBlk.StatelessBlock.Txs { + if tx.ID() == txid { + // found. + return nil + } + } + // keep iterating backward. + lastAcceptedBlockID = blk.Parent() + blk, err = i.vm.GetBlock(ctx, lastAcceptedBlockID) + if err != nil { + return ErrTxNotFound + } + } +} + +func (n *Network) URIs() []string { + return n.uris +} + +func (*Network) Configuration() workload.TestNetworkConfiguration { + return networkConfig +} diff --git a/tests/registry/registry.go b/tests/registry/registry.go new file mode 100644 index 0000000000..55c6e9cae2 --- /dev/null +++ b/tests/registry/registry.go @@ -0,0 +1,46 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package registry + +import ( + "github.com/onsi/ginkgo/v2" + + "github.com/ava-labs/hypersdk/tests/workload" +) + +type TestFunc func(t ginkgo.FullGinkgoTInterface, tn workload.TestNetwork) + +type namedTest struct { + Fnc TestFunc + Name string +} +type Registry struct { + tests []namedTest +} + +func (r *Registry) Add(name string, f TestFunc) { + r.tests = append(r.tests, namedTest{Fnc: f, Name: name}) +} + +func (r *Registry) List() []namedTest { + if r == nil { + return []namedTest{} + } + return r.tests +} + +// we need to pre-register all the test registries that are created externally in order to comply with the ginko execution order. +// i.e. the global `var _ = ginkgo.Describe` used in the integration/e2e tests need to have this field populated before the iteration +// over the top level nodes. +var testRegistries = map[*Registry]bool{} + +func Register(registry *Registry, name string, f TestFunc) bool { + registry.Add(name, f) + testRegistries[registry] = true + return true +} + +func GetTestsRegistries() map[*Registry]bool { + return testRegistries +} diff --git a/tests/workload/network.go b/tests/workload/network.go new file mode 100644 index 0000000000..b62de6bf18 --- /dev/null +++ b/tests/workload/network.go @@ -0,0 +1,55 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package workload + +import ( + "context" + + "github.com/ava-labs/hypersdk/chain" +) + +type TestNetwork interface { + ConfirmTxs(context.Context, []*chain.Transaction) error + GenerateTx(context.Context, []chain.Action, chain.AuthFactory) (*chain.Transaction, error) + URIs() []string + Configuration() TestNetworkConfiguration +} + +// TestNetworkConfiguration is an interface, implemented by VM-specific tests +// to store information regarding the test network prior to it's invocation, and +// retrieve it during execution. All implementations must be thread-safe. +type TestNetworkConfiguration interface { + GenesisBytes() []byte + Name() string + Parser() chain.Parser +} + +// DefaultTestNetworkConfiguration struct is the common test configuration that a test framework would need to provide +// in order to deploy a network. A test would typically embed this as part of it's network configuration structure. +type DefaultTestNetworkConfiguration struct { + genesisBytes []byte + name string + parser chain.Parser +} + +func (d DefaultTestNetworkConfiguration) GenesisBytes() []byte { + return d.genesisBytes +} + +func (d DefaultTestNetworkConfiguration) Name() string { + return d.name +} + +func (d DefaultTestNetworkConfiguration) Parser() chain.Parser { + return d.parser +} + +// NewDefaultTestNetworkConfiguration creates a new DefaultTestNetworkConfiguration object. +func NewDefaultTestNetworkConfiguration(genesisBytes []byte, name string, parser chain.Parser) DefaultTestNetworkConfiguration { + return DefaultTestNetworkConfiguration{ + genesisBytes: genesisBytes, + name: name, + parser: parser, + } +} diff --git a/tests/workload/transactions.go b/tests/workload/transactions.go index 5349cae048..ce75b0e0ac 100644 --- a/tests/workload/transactions.go +++ b/tests/workload/transactions.go @@ -15,79 +15,34 @@ import ( const reachedAcceptedTipSleepInterval = 10 * time.Millisecond -// TxWorkloadFactory prescribes an exact interface for generating transactions to test on a given environment -// and a sized sequence of transactions to test on a given environment and reach a particular state -type TxWorkloadFactory interface { - // NewWorkloads returns a set of TxWorkloadIterators from the VM. VM developers can use this function - // to define each sequence of transactions that should be tested. - // TODO: switch from workload generator to procedural test style for VM-defined workloads - NewWorkloads(uri string) ([]TxWorkloadIterator, error) - // Generates a new TxWorkloadIterator that generates a sequence of transactions of the given size. - NewSizedTxWorkload(uri string, size int) (TxWorkloadIterator, error) -} - type TxAssertion func(ctx context.Context, require *require.Assertions, uri string) -// TxWorkloadIterator provides an interface for generating a sequence of transactions and corresponding assertions. -// The caller must proceed in the following sequence: -// 1. Next -// 2. GenerateTxWithAssertion -// 3. (Optional) execute the returned assertion on an arbitrary number of URIs -// -// This pattern allows the workload to define how many transactions must be generated. For example, -// a CFMM application may define a set of workloads that define each sequence of actions that should be tested -// against different network configurations such as: -// 1. Create Liquidity Pool -// 2. Add Liquidity -// 3. Swap -// -// To handle tx expiry correctly, the workload must generate txs on demand (right before issuance) rather than -// returning a slice of txs, which may expire before they are issued. -type TxWorkloadIterator interface { - // Next returns true iff there are more transactions to generate. - Next() bool - // GenerateTxWithAssertion generates a new transaction and an assertion function that confirms - // 1. The tx was accepted on the provided URI - // 2. The state was updated as expected according to the provided URI - GenerateTxWithAssertion(context.Context) (*chain.Transaction, TxAssertion, error) +type TxGenerator interface { + // GenerateTx generates a new transaction and an assertion function that confirms + // the transaction was accepted by the network + GenerateTx(context.Context, string) (*chain.Transaction, TxAssertion, error) } -func ExecuteWorkload(ctx context.Context, require *require.Assertions, uris []string, generator TxWorkloadIterator) { - submitClient := jsonrpc.NewJSONRPCClient(uris[0]) - - for generator.Next() { - tx, confirm, err := generator.GenerateTxWithAssertion(ctx) - require.NoError(err) - - _, err = submitClient.SubmitTx(ctx, tx.Bytes()) - require.NoError(err) - - for _, uri := range uris { - confirm(ctx, require, uri) - } - } +type TxWorkload struct { + Generator TxGenerator } -func GenerateNBlocks(ctx context.Context, require *require.Assertions, uris []string, factory TxWorkloadFactory, n uint64) { +func (w *TxWorkload) GenerateBlocks(ctx context.Context, require *require.Assertions, uris []string, blocks int) { uri := uris[0] - generator, err := factory.NewSizedTxWorkload(uri, int(n)) - require.NoError(err) + // generate [blocks] num txs client := jsonrpc.NewJSONRPCClient(uri) _, startHeight, _, err := client.Accepted(ctx) require.NoError(err) height := startHeight - targetheight := startHeight + n + targetheight := startHeight + uint64(blocks) - for generator.Next() && height < targetheight { - tx, confirm, err := generator.GenerateTxWithAssertion(ctx) + for height < targetheight { + tx, confirm, err := w.Generator.GenerateTx(ctx, uri) require.NoError(err) - _, err = client.SubmitTx(ctx, tx.Bytes()) require.NoError(err) - confirm(ctx, require, uri) - _, acceptedHeight, _, err := client.Accepted(ctx) require.NoError(err) height = acceptedHeight @@ -106,23 +61,39 @@ func GenerateNBlocks(ctx context.Context, require *require.Assertions, uris []st } } -func GenerateUntilStop( +// GenerateTxs generates transactions using the provided TxGenerator until the generator +// can no longer generate transactions +// issues tx through clientURI and confirms against each uri in confirmURIs +func (w *TxWorkload) GenerateTxs(ctx context.Context, require *require.Assertions, numTxs int, clientURI string, confirmUris []string) { + submitClient := jsonrpc.NewJSONRPCClient(confirmUris[0]) + + for i := 0; i < numTxs; i++ { + tx, confirm, err := w.Generator.GenerateTx(ctx, clientURI) + require.NoError(err) + + _, err = submitClient.SubmitTx(ctx, tx.Bytes()) + require.NoError(err) + + for _, uri := range confirmUris { + confirm(ctx, require, uri) + } + } +} + +func (w *TxWorkload) GenerateUntilStop( ctx context.Context, require *require.Assertions, uris []string, - generator TxWorkloadIterator, + maxToGenerate int, stopChannel <-chan struct{}, ) { submitClient := jsonrpc.NewJSONRPCClient(uris[0]) - for { + for i := 0; i < maxToGenerate; i++ { select { case <-stopChannel: return default: - if !generator.Next() { - return - } - tx, confirm, err := generator.GenerateTxWithAssertion(ctx) + tx, confirm, err := w.Generator.GenerateTx(ctx, uris[0]) if err != nil { time.Sleep(1 * time.Second) continue 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/defaultvm/vm.go b/vm/defaultvm/vm.go index 3ee7d30f1a..598f188775 100644 --- a/vm/defaultvm/vm.go +++ b/vm/defaultvm/vm.go @@ -10,6 +10,7 @@ import ( "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/codec" "github.com/ava-labs/hypersdk/extension/externalsubscriber" "github.com/ava-labs/hypersdk/genesis" "github.com/ava-labs/hypersdk/vm" @@ -34,10 +35,11 @@ func NewDefaultOptions() []vm.Option { func New( v *version.Semantic, genesisFactory genesis.GenesisAndRuleFactory, - stateManager chain.StateManager, - actionRegistry chain.ActionRegistry, - authRegistry chain.AuthRegistry, - outputRegistry chain.OutputRegistry, + balanceHandler chain.BalanceHandler, + metadataManager chain.MetadataManager, + actionCodec *codec.TypeParser[chain.Action], + authCodec *codec.TypeParser[chain.Auth], + outputCodec *codec.TypeParser[codec.Typed], authEngine map[uint8]vm.AuthEngine, options ...vm.Option, ) (*vm.VM, error) { @@ -45,10 +47,11 @@ func New( return vm.New( v, genesisFactory, - stateManager, - actionRegistry, - authRegistry, - outputRegistry, + balanceHandler, + metadataManager, + actionCodec, + authCodec, + outputCodec, authEngine, options..., ) diff --git a/vm/resolutions.go b/vm/resolutions.go index 254105d2ff..a792dd3d88 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" @@ -50,16 +51,16 @@ func (vm *VM) SubnetID() ids.ID { return vm.snowCtx.SubnetID } -func (vm *VM) ActionRegistry() chain.ActionRegistry { - return vm.actionRegistry +func (vm *VM) ActionCodec() *codec.TypeParser[chain.Action] { + return vm.actionCodec } -func (vm *VM) OutputRegistry() chain.OutputRegistry { - return vm.outputRegistry +func (vm *VM) OutputCodec() *codec.TypeParser[codec.Typed] { + return vm.outputCodec } -func (vm *VM) AuthRegistry() chain.AuthRegistry { - return vm.authRegistry +func (vm *VM) AuthCodec() *codec.TypeParser[chain.Auth] { + return vm.authCodec } func (vm *VM) AuthVerifiers() workers.Workers { @@ -382,8 +383,12 @@ func (vm *VM) Genesis() genesis.Genesis { return vm.genesis } -func (vm *VM) StateManager() chain.StateManager { - return vm.stateManager +func (vm *VM) BalanceHandler() chain.BalanceHandler { + return vm.balanceHandler +} + +func (vm *VM) MetadataManager() chain.MetadataManager { + return vm.metadataManager } func (vm *VM) RecordRootCalculated(t time.Duration) { @@ -467,7 +472,7 @@ func (vm *VM) RecordClearedMempool() { } func (vm *VM) UnitPrices(context.Context) (fees.Dimensions, error) { - v, err := vm.stateDB.Get(chain.FeeKey(vm.StateManager().FeeKey())) + v, err := vm.stateDB.Get(chain.FeeKey(vm.MetadataManager().FeePrefix())) if err != nil { return fees.Dimensions{}, err } diff --git a/vm/vm.go b/vm/vm.go index 5b1e0bbe83..1a4548a288 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -29,6 +29,7 @@ import ( "github.com/ava-labs/hypersdk/api" "github.com/ava-labs/hypersdk/chain" + "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/event" "github.com/ava-labs/hypersdk/fees" "github.com/ava-labs/hypersdk/genesis" @@ -93,18 +94,17 @@ type VM struct { stateDB merkledb.MerkleDB vmDB database.Database handlers map[string]http.Handler - stateManager chain.StateManager - actionRegistry chain.ActionRegistry - authRegistry chain.AuthRegistry - outputRegistry chain.OutputRegistry + balanceHandler chain.BalanceHandler + metadataManager chain.MetadataManager + actionCodec *codec.TypeParser[chain.Action] + authCodec *codec.TypeParser[chain.Auth] + outputCodec *codec.TypeParser[codec.Typed] authEngine map[uint8]AuthEngine network *p2p.Network tracer avatrace.Tracer mempool *mempool.Mempool[*chain.Transaction] - // DSMR - // track all accepted but still valid txs (replay protection) seen *emap.EMap[*chain.Transaction] startSeenTime int64 @@ -151,10 +151,11 @@ type VM struct { func New( v *version.Semantic, genesisFactory genesis.GenesisAndRuleFactory, - stateManager chain.StateManager, - actionRegistry chain.ActionRegistry, - authRegistry chain.AuthRegistry, - outputRegistry chain.OutputRegistry, + balanceHandler chain.BalanceHandler, + metadataManager chain.MetadataManager, + actionCodec *codec.TypeParser[chain.Action], + authCodec *codec.TypeParser[chain.Auth], + outputCodec *codec.TypeParser[codec.Typed], authEngine map[uint8]AuthEngine, options ...Option, ) (*VM, error) { @@ -168,11 +169,12 @@ func New( return &VM{ v: v, - stateManager: stateManager, + balanceHandler: balanceHandler, + metadataManager: metadataManager, config: NewConfig(), - actionRegistry: actionRegistry, - authRegistry: authRegistry, - outputRegistry: outputRegistry, + actionCodec: actionCodec, + authCodec: authCodec, + outputCodec: outputCodec, authEngine: authEngine, genesisAndRuleFactory: genesisFactory, options: options, @@ -357,7 +359,7 @@ func (vm *VM) Initialize( snowCtx.Log.Info("initialized vm from last accepted", zap.Stringer("block", blk.ID())) } else { sps := state.NewSimpleMutable(vm.stateDB) - if err := vm.genesis.InitializeState(ctx, vm.tracer, sps, vm.stateManager); err != nil { + if err := vm.genesis.InitializeState(ctx, vm.tracer, sps, vm.balanceHandler); err != nil { snowCtx.Log.Error("could not set genesis state", zap.Error(err)) return err } @@ -372,7 +374,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, @@ -386,10 +388,10 @@ func (vm *VM) Initialize( // Update chain metadata sps = state.NewSimpleMutable(vm.stateDB) - if err := sps.Insert(ctx, chain.HeightKey(vm.StateManager().HeightKey()), binary.BigEndian.AppendUint64(nil, 0)); err != nil { + if err := sps.Insert(ctx, chain.HeightKey(vm.MetadataManager().HeightPrefix()), binary.BigEndian.AppendUint64(nil, 0)); err != nil { return err } - if err := sps.Insert(ctx, chain.TimestampKey(vm.StateManager().TimestampKey()), binary.BigEndian.AppendUint64(nil, 0)); err != nil { + if err := sps.Insert(ctx, chain.TimestampKey(vm.MetadataManager().TimestampPrefix()), binary.BigEndian.AppendUint64(nil, 0)); err != nil { return err } genesisRules := vm.Rules(0) @@ -399,7 +401,7 @@ func (vm *VM) Initialize( feeManager.SetUnitPrice(i, minUnitPrice[i]) snowCtx.Log.Info("set genesis unit price", zap.Int("dimension", int(i)), zap.Uint64("price", feeManager.UnitPrice(i))) } - if err := sps.Insert(ctx, chain.FeeKey(vm.StateManager().FeeKey()), feeManager.Bytes()); err != nil { + if err := sps.Insert(ctx, chain.FeeKey(vm.MetadataManager().FeePrefix()), feeManager.Bytes()); err != nil { return err } @@ -881,7 +883,7 @@ func (vm *VM) Submit( // This will error if a block does not yet have processed state. return []error{err} } - feeRaw, err := view.GetValue(ctx, chain.FeeKey(vm.StateManager().FeeKey())) + feeRaw, err := view.GetValue(ctx, chain.FeeKey(vm.MetadataManager().FeePrefix())) if err != nil { return []error{err} } @@ -918,7 +920,7 @@ func (vm *VM) Submit( } // Ensure state keys are valid - _, err := tx.StateKeys(vm.stateManager) + _, err := tx.StateKeys(vm.balanceHandler) if err != nil { errs = append(errs, ErrNotAdded) continue @@ -926,13 +928,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. @@ -961,7 +957,7 @@ func (vm *VM) Submit( // Note, [PreExecute] ensures that the pending transaction does not have // an expiry time further ahead than [ValidityWindow]. This ensures anything // added to the [Mempool] is immediately executable. - if err := tx.PreExecute(ctx, nextFeeManager, vm.stateManager, r, view, now); err != nil { + if err := tx.PreExecute(ctx, nextFeeManager, vm.balanceHandler, r, view, now); err != nil { errs = append(errs, err) continue } diff --git a/x/contracts/examples/counter-external/src/lib.rs b/x/contracts/examples/counter-external/src/lib.rs index 9a7f430a3d..f1a6f2cf11 100644 --- a/x/contracts/examples/counter-external/src/lib.rs +++ b/x/contracts/examples/counter-external/src/lib.rs @@ -35,9 +35,8 @@ mod tests { #[test] fn get_value_exists() { - let mut ctx = Context::new(); - let external = Address::new([0; 33]); - let of = Address::new([1; 33]); + let [external, of] = [0, 1].map(|i| Address::new([i; 33])); + let mut ctx = Context::with_actor(external); // mock `get_value` external contract call to return `value` let value = 5_u64; diff --git a/x/contracts/examples/multisig/src/lib.rs b/x/contracts/examples/multisig/src/lib.rs index 9afc20fed5..23dd536830 100644 --- a/x/contracts/examples/multisig/src/lib.rs +++ b/x/contracts/examples/multisig/src/lib.rs @@ -283,8 +283,7 @@ mod tests { let voters = Voters::Addresses(HashSet::from([bob])); let quorum = NonZeroU32::new(1).unwrap(); - let mut ctx = Context::new(); - ctx.set_actor(bob); + let mut ctx = Context::with_actor(bob); propose(&mut ctx, proposal, voters, quorum); } @@ -301,8 +300,7 @@ mod tests { args: Default::default(), }; - let mut ctx = Context::new(); - ctx.set_actor(bob); + let mut ctx = Context::with_actor(bob); propose(&mut ctx, proposal, voters, quorum); } @@ -330,8 +328,7 @@ mod tests { args: vec![], }; - let mut ctx = Context::new(); - ctx.set_actor(bob); + let mut ctx = Context::with_actor(bob); let id = propose(&mut ctx, proposal, voters, NonZeroU32::new(quorum).unwrap()); @@ -357,8 +354,7 @@ mod tests { args: vec![], }; - let ctx = &mut Context::new(); - ctx.set_actor(bob); + let ctx = &mut Context::with_actor(bob); let id = propose(ctx, proposal, voters, quorum); @@ -381,8 +377,7 @@ mod tests { args: args.to_vec(), }; - let mut ctx = Context::new(); - ctx.set_actor(bob); + let mut ctx = Context::with_actor(bob); let id = propose(&mut ctx, proposal, voters, quorum); @@ -414,8 +409,7 @@ mod tests { args: args.to_vec(), }; - let mut ctx = Context::new(); - ctx.set_actor(bob); + let mut ctx = Context::with_actor(bob); let id = propose(&mut ctx, proposal, voters, quorum); let result = "hello world"; @@ -450,8 +444,7 @@ mod tests { args: args.to_vec(), }; - let mut ctx = Context::new(); - ctx.set_actor(bob); + let mut ctx = Context::with_actor(bob); let id = propose(&mut ctx, proposal, voters, quorum); ctx.set_actor(charlie); @@ -477,8 +470,7 @@ mod tests { args: args.to_vec(), }; - let mut ctx = Context::new(); - ctx.set_actor(bob); + let mut ctx = Context::with_actor(bob); let id = propose(&mut ctx, proposal, voters, quorum); diff --git a/x/contracts/examples/nft/Cargo.toml b/x/contracts/examples/nft/Cargo.toml new file mode 100644 index 0000000000..d9415b7682 --- /dev/null +++ b/x/contracts/examples/nft/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "nft" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib"] + +[dependencies] +wasmlanche = { workspace = true, features = ["debug"] } + +[dev-dependencies] +wasmlanche = { workspace = true, features = ["debug", "test"] } + +[build-dependencies] +wasmlanche = { workspace = true, features = ["build"] } + +[features] +bindings = ["wasmlanche/bindings"] diff --git a/x/contracts/examples/nft/build.rs b/x/contracts/examples/nft/build.rs new file mode 100644 index 0000000000..8999b02446 --- /dev/null +++ b/x/contracts/examples/nft/build.rs @@ -0,0 +1,6 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +fn main() { + wasmlanche::build::build_wasm(); +} diff --git a/x/contracts/examples/nft/src/lib.rs b/x/contracts/examples/nft/src/lib.rs new file mode 100644 index 0000000000..359ffa9822 --- /dev/null +++ b/x/contracts/examples/nft/src/lib.rs @@ -0,0 +1,691 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +use wasmlanche::{public, state_schema, Address, Context}; + +pub type Units = u64; +pub type TokenId = usize; + +// code comments from https://github.com/OpenZeppelin/openzeppelin-contracts/blob/28aed34dc5e025e61ea0390c18cac875bfde1a78/contracts/token/ERC721/ERC721.sol +// for comparison to EVM implementation +// not all functions are provided here + +state_schema! { + // string private _name; + Name => String, + // string private _symbol; + Symbol => String, + // mapping(uint256 tokenId => address) private _owners; + Owner(TokenId) => Address, + // mapping(address owner => uint256) private _balances; + Balance(Address) => Units, + // mapping(uint256 tokenId => address) private _tokenApprovals; + Approval(TokenId) => Address, + // mapping(address owner => mapping(address operator => bool)) private _operatorApprovals; + OperatorApproval(Address, Address) => bool, +} + +// /** +// * @dev Initializes the contract by setting a `name` and a `symbol` to the token collection. +// */ +// constructor(string memory name_, string memory symbol_) { +// _name = name_; +// _symbol = symbol_; +// } +#[public] +pub fn init(ctx: &mut Context, name: String, symbol: String) { + match (ctx.get(Name), ctx.get(Symbol)) { + (Ok(None), Ok(None)) => {} + _ => panic!("name and symbol already initialized"), + } + + ctx.store(((Name, name), (Symbol, symbol))) + .expect("failed to serialize name and symbol"); +} + +// /** +// * @dev See {IERC721Metadata-name}. +// */ +// function name() public view virtual returns (string memory) { +// return _name; +// } +#[public] +pub fn name(ctx: &mut Context) -> String { + ctx.get(Name) + .expect("failed to deserialize") + .expect("name not set") +} + +// /** +// * @dev See {IERC721Metadata-symbol}. +// */ +// function symbol() public view virtual returns (string memory) { +// return _symbol; +// } +#[public] +pub fn symbol(ctx: &mut Context) -> String { + ctx.get(Symbol) + .expect("failed to deserialize") + .expect("symbol not set") +} + +// /** +// * @dev See {IERC721-balanceOf}. +// */ +// function balanceOf(address owner) public view virtual returns (uint256) { +// if (owner == address(0)) { +// revert ERC721InvalidOwner(address(0)); +// } +// return _balances[owner]; +// } +#[public] +pub fn balance_of(ctx: &mut Context, owner: Address) -> Units { + if owner == Address::ZERO { + panic!("invalid owner"); + } + + ctx.get(Balance(owner)) + .expect("failed to deserialize") + .unwrap_or_default() +} + +// /** +// * @dev See {IERC721-ownerOf}. +// */ +// function ownerOf(uint256 tokenId) public view virtual returns (address) { +// return _requireOwned(tokenId); +// } +#[public] +pub fn owner_of(ctx: &mut Context, token_id: TokenId) -> Address { + ctx.get(Owner(token_id)) + .expect("failed to deserialize") + .unwrap_or_default() +} + +// /** +// * @dev See {IERC721-approve}. +// */ +// function approve(address to, uint256 tokenId) public virtual { +// _approve(to, tokenId, _msgSender()); +// } +#[public] +pub fn approve(ctx: &mut Context, to: Address, token_id: TokenId) { + let actor = ctx.actor(); + let owner = owner_of(ctx, token_id); + + if owner != actor && !is_approved_for_all(ctx, owner, actor) { + panic!("not the owner"); + } + + ctx.store_by_key(Approval(token_id), to) + .expect("failed to serialize") +} + +// /** +// * @dev See {IERC721-setApprovalForAll}. +// */ +// function setApprovalForAll(address operator, bool approved) public virtual { +// _setApprovalForAll(_msgSender(), operator, approved); +// } +#[public] +pub fn set_approval_for_all(ctx: &mut Context, operator: Address, approved: bool) { + let actor = ctx.actor(); + + ctx.store_by_key(OperatorApproval(actor, operator), approved) + .expect("failed to serialize approval"); +} + +// /** +// * @dev See {IERC721-isApprovedForAll}. +// */ +// function isApprovedForAll(address owner, address operator) public view virtual returns (bool) { +// return _operatorApprovals[owner][operator]; +// } +#[public] +pub fn is_approved_for_all(ctx: &mut Context, owner: Address, operator: Address) -> bool { + ctx.get(OperatorApproval(owner, operator)) + .expect("failed to deserialize") + .unwrap_or_default() +} + +#[cfg(not(feature = "bindings"))] +fn is_approved(ctx: &mut Context, operator: Address, token_id: TokenId) -> bool { + let owner = owner_of(ctx, token_id); + + operator == owner + || is_approved_for_token(ctx, operator, token_id) + || is_approved_for_all(ctx, owner, operator) +} + +#[cfg(not(feature = "bindings"))] +fn is_approved_for_token(ctx: &mut Context, actor: Address, token_id: TokenId) -> bool { + ctx.get(Approval(token_id)) + .expect("failed to deserialize") + .map(|approved| actor == approved) + .unwrap_or_default() +} + +// /** +// * @dev See {IERC721-transferFrom}. +// */ +// function transferFrom(address from, address to, uint256 tokenId) public virtual { +// if (to == address(0)) { +// revert ERC721InvalidReceiver(address(0)); +// } +// // Setting an "auth" arguments enables the `_isAuthorized` check which verifies that the token exists +// // (from != 0). Therefore, it is not needed to verify that the return value is not 0 here. +// address previousOwner = _update(to, tokenId, _msgSender()); +// if (previousOwner != from) { +// revert ERC721IncorrectOwner(from, tokenId, previousOwner); +// } +// } +#[public] +pub fn transfer_from(ctx: &mut Context, from: Address, to: Address, token_id: TokenId) { + let operator = ctx.actor(); + let owner = owner_of(ctx, token_id); + + if from != owner { + panic!("not the owner"); + } + + if !is_approved(ctx, operator, token_id) { + panic!("not approved"); + } + + let from_balance = balance_of(ctx, from); + + ctx.store(( + (Owner(token_id), to), + (Approval(token_id), Address::ZERO), + (Balance(from), from_balance - 1), + )) + .expect("failed to serialize"); + + if to != Address::ZERO { + let to_balance = balance_of(ctx, to); + ctx.store_by_key(Balance(to), to_balance + 1) + .expect("failed to serialize"); + } +} + +// /** +// * @dev Mints `tokenId` and transfers it to `to`. +// * +// * WARNING: Usage of this method is discouraged, use {_safeMint} whenever possible +// * +// * Requirements: +// * +// * - `tokenId` must not exist. +// * - `to` cannot be the zero address. +// * +// * Emits a {Transfer} event. +// */ +// function _mint(address to, uint256 tokenId) internal { +// if (to == address(0)) { +// revert ERC721InvalidReceiver(address(0)); +// } +// address previousOwner = _update(to, tokenId, address(0)); +// if (previousOwner != address(0)) { +// revert ERC721InvalidSender(address(0)); +// } +// } +#[public] +pub fn mint(ctx: &mut Context, to: Address, token_id: TokenId) { + let owner = owner_of(ctx, token_id); + + if owner != Address::ZERO { + panic!("token already exists"); + } + + if to == Address::ZERO { + panic!("invalid receiver"); + } + + let to_balance = balance_of(ctx, to); + + let new_owner_pair = (Owner(token_id), to); + let new_owner_balance_pair = (Balance(to), to_balance + 1); + + ctx.store((new_owner_pair, new_owner_balance_pair)) + .expect("failed to serialize") +} + +// /** +// * @dev Destroys `tokenId`. +// * The approval is cleared when the token is burned. +// * This is an internal function that does not check if the sender is authorized to operate on the token. +// * +// * Requirements: +// * +// * - `tokenId` must exist. +// * +// * Emits a {Transfer} event. +// */ +// function _burn(uint256 tokenId) internal { +// address previousOwner = _update(address(0), tokenId, address(0)); +// if (previousOwner == address(0)) { +// revert ERC721NonexistentToken(tokenId); +// } +// } +#[public] +pub fn burn(ctx: &mut Context, token_id: TokenId) { + let owner = owner_of(ctx, token_id); + + if owner == Address::ZERO { + panic!("token does not exist"); + } + + transfer_from(ctx, owner, Address::ZERO, token_id); +} + +#[cfg(all(test, not(feature = "bindings")))] +mod tests { + use super::*; + + #[test] + fn balance_is_zero_when_no_owner() { + let bob = Address::new([1; Address::LEN]); + let mut ctx = Context::with_actor(bob); + + let balance = balance_of(&mut ctx, bob); + assert_eq!(balance, 0); + } + + #[test] + fn mint_sets_owner() { + let alice = Address::new([1; Address::LEN]); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + let owner = owner_of(&mut ctx, token_id); + assert_eq!(owner, alice); + } + + #[test] + fn mint_increases_balance() { + let alice = Address::new([1; Address::LEN]); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + let balance = balance_of(&mut ctx, alice); + assert_eq!(balance, 1); + + mint(&mut ctx, alice, token_id + 1); + let balance = balance_of(&mut ctx, alice); + assert_eq!(balance, 2); + } + + #[test] + fn mint_does_not_set_approval() { + let alice = Address::new([1; Address::LEN]); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + let approval = ctx.get(Approval(token_id)).expect("failed to deserialize"); + assert_eq!(approval, None); + } + + // burn + #[test] + fn burn_decreases_balance() { + let alice = Address::new([1; Address::LEN]); + let mut ctx = Context::with_actor(alice); + let tokens = [0, 1]; + + tokens.into_iter().for_each(|token_id| { + ctx.store_by_key(Owner(token_id), alice) + .expect("failed to serialize") + }); + + ctx.store_by_key(Balance(alice), tokens.len() as u64) + .expect("failed to serialize"); + + let balance = balance_of(&mut ctx, alice); + assert_eq!(balance, tokens.len() as u64); + + tokens + .into_iter() + .enumerate() + .map(|(i, id)| (i + 1, id)) + .map(|(burn_count, id)| (tokens.len() - burn_count, id)) + .for_each(|(expected_balance, token_id)| { + burn(&mut ctx, token_id); + let balance = balance_of(&mut ctx, alice); + assert_eq!(balance, expected_balance as u64); + }); + } + + #[test] + fn burn_results_in_owner_of_zero_address() { + let alice = Address::new([1; Address::LEN]); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + burn(&mut ctx, token_id); + + let owner = owner_of(&mut ctx, token_id); + assert_eq!(owner, Address::ZERO); + } + + #[test] + #[should_panic = "not the owner"] + fn burn_results_in_old_owner_not_being_able_to_approve() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + approve(&mut ctx, bob, token_id); + + burn(&mut ctx, token_id); + + approve(&mut ctx, bob, token_id); + } + + #[test] + #[should_panic = "not the owner"] + fn burn_results_in_old_owner_not_being_able_to_tranfer() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + transfer_from(&mut ctx, alice, bob, token_id); + + ctx.set_actor(bob); + + burn(&mut ctx, token_id); + + transfer_from(&mut ctx, bob, alice, token_id); + } + + #[test] + #[should_panic = "not approved"] + fn burn_can_only_be_done_by_owner() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + ctx.set_actor(bob); + burn(&mut ctx, token_id); + } + + #[test] + fn burn_can_be_done_by_token_approved() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + approve(&mut ctx, bob, token_id); + + ctx.set_actor(bob); + burn(&mut ctx, token_id); + + let new_owner = owner_of(&mut ctx, token_id); + assert_eq!(new_owner, Address::ZERO); + } + + #[test] + fn burn_can_be_done_by_approved_all() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + set_approval_for_all(&mut ctx, bob, true); + + ctx.set_actor(bob); + burn(&mut ctx, token_id); + + let new_owner = owner_of(&mut ctx, token_id); + assert_eq!(new_owner, Address::ZERO); + } + + #[test] + fn transfer_increases_receiver_balance_by_one() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + let bob_balance = balance_of(&mut ctx, bob); + assert_eq!(bob_balance, 0); + + transfer_from(&mut ctx, alice, bob, token_id); + + let bob_balance = balance_of(&mut ctx, bob); + assert_eq!(bob_balance, 1); + } + + #[test] + fn transfer_decreases_sender_balance_by_one() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + let alice_balance = balance_of(&mut ctx, alice); + assert_eq!(alice_balance, 1); + + transfer_from(&mut ctx, alice, bob, token_id); + + let alice_balance = balance_of(&mut ctx, alice); + assert_eq!(alice_balance, 0); + } + + #[test] + fn transfer_sets_new_owner() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + let owner = owner_of(&mut ctx, token_id); + assert_eq!(owner, alice); + + transfer_from(&mut ctx, alice, bob, token_id); + + let owner = owner_of(&mut ctx, token_id); + assert_eq!(owner, bob); + } + + #[test] + fn transfer_clears_approval() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + approve(&mut ctx, bob, token_id); + + transfer_from(&mut ctx, alice, bob, token_id); + + let approval = ctx.get(Approval(token_id)).expect("failed to deserialize"); + assert_eq!(approval, Some(Address::ZERO)); + } + + #[test] + #[should_panic = "not the owner"] + fn transfer_prevents_old_owner_from_approving() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + transfer_from(&mut ctx, alice, bob, token_id); + + approve(&mut ctx, bob, token_id); + } + + #[test] + #[should_panic = "not approved"] + fn transfer_prevents_old_owner_from_transfering() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + transfer_from(&mut ctx, alice, bob, token_id); + + transfer_from(&mut ctx, bob, alice, token_id); + } + + #[test] + fn transfer_is_allowed_by_approved_actor() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + approve(&mut ctx, bob, token_id); + + ctx.set_actor(bob); + transfer_from(&mut ctx, alice, bob, token_id); + + let owner = owner_of(&mut ctx, token_id); + assert_eq!(owner, bob); + } + + #[test] + #[should_panic = "not approved"] + fn transfer_is_not_allowed_by_non_approved_actor() { + let [alice, bob, charlie] = [1, 2, 3].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + approve(&mut ctx, bob, token_id); + + ctx.set_actor(charlie); + transfer_from(&mut ctx, alice, bob, token_id); + } + + #[test] + fn approve_can_be_set_by_owner() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + approve(&mut ctx, bob, token_id); + + let approval = ctx.get(Approval(token_id)).expect("failed to deserialize"); + assert_eq!(approval, Some(bob)); + } + + #[test] + #[should_panic = "not the owner"] + fn approve_cannot_be_changed_by_approved() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + approve(&mut ctx, bob, token_id); + + ctx.set_actor(bob); + approve(&mut ctx, alice, token_id); + } + + #[test] + #[should_panic = "not the owner"] + fn approve_cannot_be_changed_by_random() { + let [alice, bob, carol] = [1, 2, 3].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + + approve(&mut ctx, bob, token_id); + + ctx.set_actor(carol); + + approve(&mut ctx, alice, token_id); + } + + #[test] + fn approve_all_can_be_set_by_actor() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + + let approval_result = ctx + .get(OperatorApproval(alice, bob)) + .expect("failed to deserialize"); + assert_eq!(approval_result, None); + + let approval = true; + set_approval_for_all(&mut ctx, bob, approval); + + let approval_result = ctx + .get(OperatorApproval(alice, bob)) + .expect("failed to deserialize"); + assert_eq!(approval_result, Some(approval)); + + let approval = false; + set_approval_for_all(&mut ctx, bob, approval); + + let approval_result = ctx + .get(OperatorApproval(alice, bob)) + .expect("failed to deserialize"); + assert_eq!(approval_result, Some(approval)); + } + + #[test] + fn approve_all_can_transfer_one() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let mut ctx = Context::with_actor(alice); + let token_id = 0; + + mint(&mut ctx, alice, token_id); + let owner = owner_of(&mut ctx, token_id); + assert_eq!(owner, alice); + + set_approval_for_all(&mut ctx, bob, true); + + ctx.set_actor(bob); + transfer_from(&mut ctx, alice, bob, token_id); + + let owner = owner_of(&mut ctx, token_id); + assert_eq!(owner, bob); + } + + #[test] + fn approve_all_can_transfer_many() { + let [alice, bob] = [1, 2].map(|i| Address::new([i; Address::LEN])); + let tokens = [0, 1]; + let mut ctx = Context::with_actor(alice); + + tokens.into_iter().for_each(|token_id| { + mint(&mut ctx, alice, token_id); + }); + + set_approval_for_all(&mut ctx, bob, true); + + ctx.set_actor(bob); + + tokens.into_iter().for_each(|token_id| { + transfer_from(&mut ctx, alice, bob, token_id); + }); + + tokens.into_iter().for_each(|token_id| { + let owner = owner_of(&mut ctx, token_id); + assert_eq!(owner, bob); + }); + } +} diff --git a/x/contracts/examples/nft/tests/integration.rs b/x/contracts/examples/nft/tests/integration.rs new file mode 100644 index 0000000000..1b8877fec7 --- /dev/null +++ b/x/contracts/examples/nft/tests/integration.rs @@ -0,0 +1,16 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +use wasmlanche::simulator::{Error, SimpleState, Simulator}; + +const CONTRACT_PATH: &str = env!("CONTRACT_PATH"); + +#[test] +fn create_contract() -> Result<(), Error> { + let mut state = SimpleState::new(); + let simulator = Simulator::new(&mut state); + + simulator.create_contract(CONTRACT_PATH)?; + + Ok(()) +} diff --git a/x/contracts/runtime/call_context_test.go b/x/contracts/runtime/call_context_test.go index a0ed808552..a040fef77b 100644 --- a/x/contracts/runtime/call_context_test.go +++ b/x/contracts/runtime/call_context_test.go @@ -9,10 +9,11 @@ import ( "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/logging" - "github.com/bytecodealliance/wasmtime-go/v14" + "github.com/bytecodealliance/wasmtime-go/v25" "github.com/stretchr/testify/require" "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/x/contracts/test" ) func TestCallContext(t *testing.T) { @@ -22,11 +23,14 @@ func TestCallContext(t *testing.T) { contractID := ids.GenerateTestID() contractAccount := codec.CreateAddress(0, contractID) stringedID := string(contractID[:]) + contractManager := NewContractStateManager(test.NewTestDB(), []byte{}) + err := contractManager.SetAccountContract(ctx, contractAccount, ContractID(stringedID)) + require.NoError(err) testStateManager := &TestStateManager{ - ContractsMap: map[string][]byte{}, - AccountMap: map[codec.Address]string{contractAccount: stringedID}, + ContractManager: contractManager, } - err := testStateManager.CompileAndSetContract(ContractID(stringedID), "call_contract") + + err = testStateManager.CompileAndSetContract(ContractID(stringedID), "call_contract") require.NoError(err) r := NewRuntime( @@ -75,12 +79,13 @@ func TestCallContextPreventOverwrite(t *testing.T) { contract1Address := codec.CreateAddress(1, contract1ID) stringedID0 := string(contract0ID[:]) + contractManager := NewContractStateManager(test.NewTestDB(), []byte{}) + err := contractManager.SetAccountContract(ctx, contract0Address, ContractID(stringedID0)) + require.NoError(err) testStateManager := &TestStateManager{ - ContractsMap: map[string][]byte{}, - AccountMap: map[codec.Address]string{contract0Address: stringedID0}, + ContractManager: contractManager, } - - err := testStateManager.CompileAndSetContract(ContractID(stringedID0), "call_contract") + err = testStateManager.CompileAndSetContract(ContractID(stringedID0), "call_contract") require.NoError(err) r := NewRuntime( @@ -94,10 +99,13 @@ func TestCallContextPreventOverwrite(t *testing.T) { }) stringedID1 := string(contract1ID[:]) + contractManager1 := NewContractStateManager(test.NewTestDB(), []byte{}) + err = contractManager.SetAccountContract(ctx, contract1Address, ContractID(stringedID1)) + require.NoError(err) testStateManager1 := &TestStateManager{ - ContractsMap: map[string][]byte{}, - AccountMap: map[codec.Address]string{contract1Address: stringedID1}, + ContractManager: contractManager1, } + err = testStateManager1.CompileAndSetContract(ContractID(stringedID1), "call_contract") require.NoError(err) diff --git a/x/contracts/runtime/config.go b/x/contracts/runtime/config.go index 9ac2449a00..1d27333c31 100644 --- a/x/contracts/runtime/config.go +++ b/x/contracts/runtime/config.go @@ -5,7 +5,7 @@ package runtime import ( "github.com/ava-labs/avalanchego/utils/units" - "github.com/bytecodealliance/wasmtime-go/v14" + "github.com/bytecodealliance/wasmtime-go/v25" ) type CompileStrategy uint8 diff --git a/x/contracts/runtime/contract.go b/x/contracts/runtime/contract.go index 8b9341630b..f7dea229ef 100644 --- a/x/contracts/runtime/contract.go +++ b/x/contracts/runtime/contract.go @@ -10,7 +10,7 @@ import ( "errors" "github.com/ava-labs/avalanchego/ids" - "github.com/bytecodealliance/wasmtime-go/v14" + "github.com/bytecodealliance/wasmtime-go/v25" "github.com/ava-labs/hypersdk/codec" ) @@ -63,21 +63,36 @@ type CallInfo struct { } func (c *CallInfo) RemainingFuel() uint64 { - remaining := c.Fuel - usedFuel, fuelEnabled := c.inst.store.FuelConsumed() - if fuelEnabled { - remaining -= usedFuel + remaining, err := c.inst.store.GetFuel() + if err != nil { + return c.Fuel } + return remaining } func (c *CallInfo) AddFuel(fuel uint64) { // only errors if fuel isn't enable, which it always will be - _ = c.inst.store.AddFuel(fuel) + remaining, err := c.inst.store.GetFuel() + if err != nil { + return + } + + _ = c.inst.store.SetFuel(remaining + fuel) } func (c *CallInfo) ConsumeFuel(fuel uint64) error { - _, err := c.inst.store.ConsumeFuel(fuel) + remaining, err := c.inst.store.GetFuel() + if err != nil { + return err + } + + if remaining < fuel { + return errors.New("out of fuel") + } + + err = c.inst.store.SetFuel(remaining - fuel) + return err } @@ -88,7 +103,12 @@ type ContractInstance struct { } func (p *ContractInstance) call(ctx context.Context, callInfo *CallInfo) ([]byte, error) { - if err := p.store.AddFuel(callInfo.Fuel); err != nil { + remaining, err := p.store.GetFuel() + if err != nil { + return nil, err + } + + if err := p.store.SetFuel(remaining + callInfo.Fuel); err != nil { return nil, err } diff --git a/x/contracts/runtime/errors.go b/x/contracts/runtime/errors.go index 90bd2db774..4c6b35f477 100644 --- a/x/contracts/runtime/errors.go +++ b/x/contracts/runtime/errors.go @@ -6,7 +6,7 @@ package runtime import ( "errors" - "github.com/bytecodealliance/wasmtime-go/v14" + "github.com/bytecodealliance/wasmtime-go/v25" ) func convertToTrap(err error) *wasmtime.Trap { diff --git a/x/contracts/runtime/import_balance_test.go b/x/contracts/runtime/import_balance_test.go index c811556e63..d6ca285565 100644 --- a/x/contracts/runtime/import_balance_test.go +++ b/x/contracts/runtime/import_balance_test.go @@ -33,7 +33,7 @@ func TestImportBalanceSendBalanceToAnotherContract(t *testing.T) { stateManager.Balances[newInstanceAddress] = 0 // contract 2 starts with 0 balance - result, err := r.CallContract(newInstanceAddress, "balance") + result, err := r.CallContract(newInstanceAddress, "balance", nil) require.NoError(err) require.Equal(uint64(0), into[uint64](result)) diff --git a/x/contracts/runtime/import_contract.go b/x/contracts/runtime/import_contract.go index 18616a9eb3..41eaba4cef 100644 --- a/x/contracts/runtime/import_contract.go +++ b/x/contracts/runtime/import_contract.go @@ -8,7 +8,7 @@ import ( "errors" "slices" - "github.com/bytecodealliance/wasmtime-go/v14" + "github.com/bytecodealliance/wasmtime-go/v25" "github.com/ava-labs/hypersdk/codec" ) diff --git a/x/contracts/runtime/import_contract_test.go b/x/contracts/runtime/import_contract_test.go index 6c6ef5362b..7100b5d98c 100644 --- a/x/contracts/runtime/import_contract_test.go +++ b/x/contracts/runtime/import_contract_test.go @@ -13,6 +13,36 @@ import ( "github.com/ava-labs/hypersdk/codec" ) +func BenchmarkDeployContract(b *testing.B) { + require := require.New(b) + + ctx := context.Background() + rt := newTestRuntime(ctx) + contract, err := rt.newTestContract("deploy_contract") + require.NoError(err) + + runtime := contract.Runtime + otherContractID := ids.GenerateTestID() + err = runtime.AddContract(otherContractID[:], codec.CreateAddress(0, otherContractID), "call_contract") + require.NoError(err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, err := contract.Call( + "deploy", + otherContractID[:]) + require.NoError(err) + + newAccount := into[codec.Address](result) + + b.StopTimer() + result, err = runtime.CallContract(newAccount, "simple_call", nil) + require.NoError(err) + require.Equal(uint64(0), into[uint64](result)) + b.StartTimer() + } +} + func TestImportContractDeployContract(t *testing.T) { require := require.New(t) ctx := context.Background() @@ -33,7 +63,7 @@ func TestImportContractDeployContract(t *testing.T) { newAccount := into[codec.Address](result) - result, err = runtime.CallContract(newAccount, "simple_call") + result, err = runtime.CallContract(newAccount, "simple_call", nil) require.NoError(err) require.Equal(uint64(0), into[uint64](result)) } diff --git a/x/contracts/runtime/imports.go b/x/contracts/runtime/imports.go index fe2ded3bc4..3fcabf8225 100644 --- a/x/contracts/runtime/imports.go +++ b/x/contracts/runtime/imports.go @@ -4,7 +4,7 @@ package runtime import ( - "github.com/bytecodealliance/wasmtime-go/v14" + "github.com/bytecodealliance/wasmtime-go/v25" "golang.org/x/exp/maps" ) diff --git a/x/contracts/runtime/manager.go b/x/contracts/runtime/manager.go new file mode 100644 index 0000000000..0c7623d39e --- /dev/null +++ b/x/contracts/runtime/manager.go @@ -0,0 +1,177 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package runtime + +import ( + "context" + "crypto/sha256" + "errors" + + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/ids" + + "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/state" +) + +var ( + _ ContractManager = &ContractStateManager{} + ErrUnknownAccount = errors.New("unknown account") + contractKeyBytes = []byte("contract") +) + +const ( + // Global directory of contractIDs to contractBytes + contractPrefix = 0x0 + + // Prefix for all contract state spaces + accountPrefix = 0x1 + // Associated data for an account, such as the contractID + accountDataPrefix = 0x0 + // State space associated with an account + accountStatePrefix = 0x1 +) + +// ContractStateManager is an out of the box implementation of the ContractManager interface. +// The contract state manager is responsible for managing all state keys associated with contracts. +type ContractStateManager struct { + db state.Mutable +} + +// NewContractStateManager returns a new ContractStateManager instance. +// [prefix] must be unique to ensures the contract's state space +// remains isolated from other state spaces in [db]. +func NewContractStateManager( + db state.Mutable, + prefix []byte, +) *ContractStateManager { + prefixedState := newPrefixStateMutable(prefix, db) + + return &ContractStateManager{ + db: prefixedState, + } +} + +// GetContractState returns a mutable state instance associated with [account]. +func (p *ContractStateManager) GetContractState(account codec.Address) state.Mutable { + return newAccountPrefixedMutable(account, p.db) +} + +// GetAccountContract grabs the associated id with [account]. The ID is the key mapping to the contractbytes +// Errors if there is no found account or an error fetching +func (p *ContractStateManager) GetAccountContract(ctx context.Context, account codec.Address) (ContractID, error) { + contractID, exists, err := p.getAccountContract(ctx, account) + if err != nil { + return ids.Empty[:], err + } + if !exists { + return ids.Empty[:], ErrUnknownAccount + } + return contractID[:], nil +} + +// [contractID] -> [contractBytes] +func (p *ContractStateManager) GetContractBytes(ctx context.Context, contractID ContractID) ([]byte, error) { + // TODO: take fee out of balance? + contractBytes, err := p.db.GetValue(ctx, contractKey(contractID)) + if err != nil { + return []byte{}, ErrUnknownAccount + } + + return contractBytes, nil +} + +func (p *ContractStateManager) NewAccountWithContract(ctx context.Context, contractID ContractID, accountCreationData []byte) (codec.Address, error) { + newID := sha256.Sum256(append(contractID, accountCreationData...)) + newAccount := codec.CreateAddress(0, newID) + return newAccount, p.SetAccountContract(ctx, newAccount, contractID) +} + +func (p *ContractStateManager) SetAccountContract(ctx context.Context, account codec.Address, contractID ContractID) error { + return p.db.Insert(ctx, accountDataKey(account[:], contractKeyBytes), contractID) +} + +// setContract stores [contract] at [contractID] +func (p *ContractStateManager) SetContractBytes( + ctx context.Context, + contractID ContractID, + contract []byte, +) error { + return p.db.Insert(ctx, contractKey(contractID[:]), contract) +} + +func contractKey(key []byte) (k []byte) { + k = make([]byte, 0, 1+len(key)) + k = append(k, contractPrefix) + k = append(k, key...) + return +} + +// Creates a key an account balance key +func accountDataKey(account []byte, key []byte) (k []byte) { + // accountPrefix + account + accountDataPrefix + key + k = make([]byte, 0, 2+len(account)+len(key)) + k = append(k, accountPrefix) + k = append(k, account...) + k = append(k, accountDataPrefix) + k = append(k, key...) + return +} + +func accountContractKey(account []byte) []byte { + return accountDataKey(account, contractKeyBytes) +} + +func (p *ContractStateManager) getAccountContract(ctx context.Context, account codec.Address) (ids.ID, bool, error) { + v, err := p.db.GetValue(ctx, accountContractKey(account[:])) + if errors.Is(err, database.ErrNotFound) { + return ids.Empty, false, nil + } + if err != nil { + return ids.Empty, false, err + } + return ids.ID(v[:ids.IDLen]), true, nil +} + +// prefixed state +type prefixedStateMutable struct { + inner state.Mutable + prefix []byte +} + +func newPrefixStateMutable(prefix []byte, inner state.Mutable) *prefixedStateMutable { + return &prefixedStateMutable{inner: inner, prefix: prefix} +} + +func (s *prefixedStateMutable) prefixKey(key []byte) (k []byte) { + k = make([]byte, len(s.prefix)+len(key)) + copy(k, s.prefix) + copy(k[len(s.prefix):], key) + return +} + +func (s *prefixedStateMutable) GetValue(ctx context.Context, key []byte) (value []byte, err error) { + return s.inner.GetValue(ctx, s.prefixKey(key)) +} + +func (s *prefixedStateMutable) Insert(ctx context.Context, key []byte, value []byte) error { + return s.inner.Insert(ctx, s.prefixKey(key), value) +} + +func (s *prefixedStateMutable) Remove(ctx context.Context, key []byte) error { + return s.inner.Remove(ctx, s.prefixKey(key)) +} + +func newAccountPrefixedMutable(account codec.Address, mutable state.Mutable) state.Mutable { + return &prefixedStateMutable{inner: mutable, prefix: accountStateKey(account[:])} +} + +// [accountPrefix] + [account] + [accountStatePrefix] = state space associated with a contract +func accountStateKey(key []byte) (k []byte) { + k = make([]byte, 2+len(key)) + k[0] = accountPrefix + copy(k[1:], key) + k[len(k)-1] = accountStatePrefix + return +} diff --git a/x/contracts/runtime/runtime.go b/x/contracts/runtime/runtime.go index 1280e74331..a870c0d409 100644 --- a/x/contracts/runtime/runtime.go +++ b/x/contracts/runtime/runtime.go @@ -9,7 +9,7 @@ import ( "github.com/ava-labs/avalanchego/cache" "github.com/ava-labs/avalanchego/utils/logging" - "github.com/bytecodealliance/wasmtime-go/v14" + "github.com/bytecodealliance/wasmtime-go/v25" "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/state" @@ -50,6 +50,8 @@ type ContractManager interface { NewAccountWithContract(ctx context.Context, contractID ContractID, accountCreationData []byte) (codec.Address, error) // SetAccountContract associates the given contract ID with the given account. SetAccountContract(ctx context.Context, account codec.Address, contractID ContractID) error + // SetContractBytes stores the compiled WASM bytes of the contract with the given ID. + SetContractBytes(ctx context.Context, contractID ContractID, contractBytes []byte) error } func NewRuntime( diff --git a/x/contracts/runtime/runtime_test.go b/x/contracts/runtime/runtime_test.go index 09ea1be587..a19aab0c7e 100644 --- a/x/contracts/runtime/runtime_test.go +++ b/x/contracts/runtime/runtime_test.go @@ -12,9 +12,178 @@ import ( "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/consts" + "github.com/ava-labs/hypersdk/x/contracts/test" ) -// Benchmarks calling a contract that returns 0 immediately +// Benchmarks the time it takes to get a module when it is not cached +// BenchmarkRuntimeModuleNotCached-10 145 10262426 ns/op 245882 B/op 9 allocs/op +func BenchmarkRuntimeModuleNotCached(b *testing.B) { + require := require.New(b) + + ctx := context.Background() + rt := newTestRuntime(ctx) + + contract, err := rt.newTestContract("simple") + require.NoError(err) + + callInfo := &CallInfo{ + Contract: contract.Address, + State: rt.StateManager, + FunctionName: "get_value", + Params: test.SerializeParams(), + } + newInfo, err := rt.callContext.createCallInfo(callInfo) + require.NoError(err) + programID, err := callInfo.State.GetAccountContract(ctx, newInfo.Contract) + require.NoError(err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err = rt.callContext.r.getModule(ctx, newInfo, programID) + require.NoError(err) + + b.StopTimer() + // reset module + rt.callContext.r.contractCache.Flush() + b.StartTimer() + } +} + +// Benchmarks the time it takes to get a module when it is cached +// BenchmarkRuntimeModuleCached-10 2429497 495.1 ns/op 32 B/op 1 allocs/op +func BenchmarkRuntimeModuleCached(b *testing.B) { + require := require.New(b) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + rt := newTestRuntime(ctx) + contract, err := rt.newTestContract("simple") + require.NoError(err) + + result, err := contract.Call("get_value") + require.NoError(err) + require.Equal(uint64(0), into[uint64](result)) + + b.ResetTimer() + + callInfo := &CallInfo{ + Contract: contract.Address, + State: rt.StateManager, + FunctionName: "get_value", + Params: test.SerializeParams(), + } + newInfo, err := rt.callContext.createCallInfo(callInfo) + require.NoError(err) + programID, err := callInfo.State.GetAccountContract(ctx, newInfo.Contract) + require.NoError(err) + + for i := 0; i < b.N; i++ { + _, err = rt.callContext.r.getModule(ctx, newInfo, programID) + require.NoError(err) + } +} + +// Benchmarks the time it takes to get an instance which happens on every contract call +// Wasmitime rust allows you to pre-instantiate the module, which is not implemented in go +// +// https://docs.rs/wasmtime/latest/wasmtime/struct.Linker.html#method.instantiate_pre +// +// BenchmarkRuntimeInstance-10 48392 28894 ns/op 265 B/op 15 allocs/op +func BenchmarkRuntimeInstance(b *testing.B) { + require := require.New(b) + + ctx := context.Background() + rt := newTestRuntime(ctx) + contract, err := rt.newTestContract("simple") + require.NoError(err) + + result, err := contract.Call("get_value") + require.NoError(err) + require.Equal(uint64(0), into[uint64](result)) + + b.ResetTimer() + + callInfo := &CallInfo{ + Contract: contract.Address, + State: rt.StateManager, + FunctionName: "get_value", + Params: test.SerializeParams(), + } + newInfo, err := rt.callContext.createCallInfo(callInfo) + require.NoError(err) + programID, err := newInfo.State.GetAccountContract(ctx, newInfo.Contract) + require.NoError(err) + + module, err := rt.callContext.r.getModule(ctx, newInfo, programID) + require.NoError(err) + b.ResetTimer() + for i := 0; i < b.N; i++ { + inst, err := rt.callContext.r.getInstance(module, rt.callContext.r.hostImports) + require.NoError(err) + _ = inst + + b.StopTimer() + // reset module + module, err = rt.callContext.r.getModule(ctx, newInfo, programID) + require.NoError(err) + b.StartTimer() + } +} + +// Benchmarks the time it takes to call a contract with everything instantiated +// BenchmarkRuntimeInstanceCall-10 32728 33143 ns/op 1629 B/op 191 allocs/op +func BenchmarkRuntimeInstanceCall(b *testing.B) { + require := require.New(b) + ctx := context.Background() + + rt := newTestRuntime(ctx) + contract, err := rt.newTestContract("simple") + require.NoError(err) + + result, err := contract.Call("get_value") + require.NoError(err) + require.Equal(uint64(0), into[uint64](result)) + + b.ResetTimer() + + callInfo := &CallInfo{ + Contract: contract.Address, + State: rt.StateManager, + FunctionName: "get_value", + Params: test.SerializeParams(), + } + newInfo, err := rt.callContext.createCallInfo(callInfo) + require.NoError(err) + programID, err := newInfo.State.GetAccountContract(ctx, newInfo.Contract) + require.NoError(err) + + module, err := rt.callContext.r.getModule(ctx, newInfo, programID) + require.NoError(err) + inst, err := rt.callContext.r.getInstance(module, rt.callContext.r.hostImports) + require.NoError(err) + b.ResetTimer() + newInfo.inst = inst + rt.callContext.r.setCallInfo(inst.store, newInfo) + + for i := 0; i < b.N; i++ { + result, err := inst.call(ctx, newInfo) + require.NoError(err) + _ = result + + rt.callContext.r.deleteCallInfo(inst.store) + b.StopTimer() + // reset module + module, err = rt.callContext.r.getModule(ctx, newInfo, programID) + require.NoError(err) + inst, err = rt.callContext.r.getInstance(module, rt.callContext.r.hostImports) + require.NoError(err) + newInfo.inst = inst + b.StartTimer() + rt.callContext.r.setCallInfo(inst.store, newInfo) + } +} + func BenchmarkRuntimeCallContractBasic(b *testing.B) { require := require.New(b) ctx := context.Background() @@ -25,7 +194,7 @@ func BenchmarkRuntimeCallContractBasic(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - result, err := contract.Call("get_value") + result, err := contract.CallWithSerializedParams("get_value", nil) require.NoError(err) require.Equal(uint64(0), into[uint64](result)) } @@ -43,9 +212,11 @@ func BenchmarkRuntimeSendValue(b *testing.B) { require.NoError(err) contract.Runtime.StateManager.(TestStateManager).Balances[contract.Address] = consts.MaxUint64 + params := test.SerializeParams(actor) + b.ResetTimer() for i := 0; i < b.N; i++ { - result, err := contract.Call("send_balance", actor) + result, err := contract.CallWithSerializedParams("send_balance", params) require.NoError(err) require.True(into[bool](result)) } @@ -66,14 +237,42 @@ func BenchmarkRuntimeBasicExternalCalls(b *testing.B) { require.NoError(err) addressOf := codec.CreateAddress(0, ids.GenerateTestID()) + params := test.SerializeParams(counterAddress, addressOf) + b.ResetTimer() for i := 0; i < b.N; i++ { - result, err := contract.Call("get_value", counterAddress, addressOf) + result, err := contract.CallWithSerializedParams("get_value", params) require.NoError(err) require.Equal(uint64(0), into[uint64](result)) } } +// Benchmark an NFT +func BenchmarkNFTMint(b *testing.B) { + require := require.New(b) + ctx := context.Background() + + rt := newTestRuntime(ctx) + nft, err := rt.newTestContract("nft") + require.NoError(err) + + _, err = nft.Call("init", "NFT", "NFT") + require.NoError(err) + + actor := codec.CreateAddress(0, ids.GenerateTestID()) + params := make([][]byte, b.N) + + for i := 0; i < b.N; i++ { + params[i] = test.SerializeParams(actor, i) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err = nft.CallWithSerializedParams("mint", params[i]) + require.NoError(err) + } +} + // Benchmarks a contract that performs an AMM swap func BenchmarkAmmSwaps(b *testing.B) { require := require.New(b) @@ -122,9 +321,11 @@ func BenchmarkAmmSwaps(b *testing.B) { _, err = amm.WithActor(lp).Call("add_liquidity", amountMint, amountMint) require.NoError(err) + params := test.SerializeParams(tokenX.Address, 150) + b.ResetTimer() for i := 0; i < b.N; i++ { - received, err := amm.WithActor(swaper).Call("swap", tokenX.Address, 150) + received, err := amm.WithActor(swaper).CallWithSerializedParams("swap", params) require.NoError(err) require.NotZero(received) } diff --git a/x/contracts/runtime/util_test.go b/x/contracts/runtime/util_test.go index 285bf3a661..8b39e469f6 100644 --- a/x/contracts/runtime/util_test.go +++ b/x/contracts/runtime/util_test.go @@ -19,26 +19,16 @@ import ( ) type TestStateManager struct { - ContractsMap map[string][]byte - AccountMap map[codec.Address]string - Balances map[codec.Address]uint64 - Mu state.Mutable + ContractManager *ContractStateManager + Balances map[codec.Address]uint64 } -func (t TestStateManager) GetAccountContract(_ context.Context, account codec.Address) (ContractID, error) { - if contractID, ok := t.AccountMap[account]; ok { - return ContractID(contractID), nil - } - return ids.Empty[:], nil +func (t TestStateManager) GetAccountContract(ctx context.Context, account codec.Address) (ContractID, error) { + return t.ContractManager.GetAccountContract(ctx, account) } -func (t TestStateManager) GetContractBytes(_ context.Context, contractID ContractID) ([]byte, error) { - contractBytes, ok := t.ContractsMap[string(contractID)] - if !ok { - return nil, errors.New("couldn't find contract") - } - - return contractBytes, nil +func (t TestStateManager) GetContractBytes(ctx context.Context, contractID ContractID) ([]byte, error) { + return t.ContractManager.GetContractBytes(ctx, contractID) } func compileContract(contractName string) ([]byte, error) { @@ -59,8 +49,8 @@ func compileContract(contractName string) ([]byte, error) { return contractBytes, nil } -func (t TestStateManager) SetContractBytes(contractID ContractID, contractBytes []byte) { - t.ContractsMap[string(contractID)] = contractBytes +func (t TestStateManager) SetContractBytes(ctx context.Context, contractID ContractID, contractBytes []byte) error { + return t.ContractManager.SetContractBytes(ctx, contractID, contractBytes) } func (t TestStateManager) CompileAndSetContract(contractID ContractID, contractName string) error { @@ -68,19 +58,15 @@ func (t TestStateManager) CompileAndSetContract(contractID ContractID, contractN if err != nil { return err } - t.SetContractBytes(contractID, contractBytes) - return nil + return t.SetContractBytes(context.Background(), contractID, contractBytes) } func (t TestStateManager) NewAccountWithContract(_ context.Context, contractID ContractID, _ []byte) (codec.Address, error) { - account := codec.CreateAddress(0, ids.GenerateTestID()) - t.AccountMap[account] = string(contractID) - return account, nil + return t.ContractManager.NewAccountWithContract(context.Background(), contractID, []byte{}) } func (t TestStateManager) SetAccountContract(_ context.Context, account codec.Address, contractID ContractID) error { - t.AccountMap[account] = string(contractID) - return nil + return t.ContractManager.SetAccountContract(context.Background(), account, contractID) } func (t TestStateManager) GetBalance(_ context.Context, address codec.Address) (uint64, error) { @@ -104,7 +90,7 @@ func (t TestStateManager) TransferBalance(ctx context.Context, from codec.Addres } func (t TestStateManager) GetContractState(address codec.Address) state.Mutable { - return &prefixedState{address: address, inner: t.Mu} + return t.ContractManager.GetContractState(address) } var _ state.Mutable = (*prefixedState)(nil) @@ -197,19 +183,17 @@ func (t *testRuntime) AddContract(contractID ContractID, account codec.Address, if err != nil { return err } - - t.StateManager.(TestStateManager).AccountMap[account] = string(contractID) - return nil + return t.StateManager.(TestStateManager).SetAccountContract(t.Context, account, contractID) } -func (t *testRuntime) CallContract(contract codec.Address, function string, params ...interface{}) ([]byte, error) { +func (t *testRuntime) CallContract(contract codec.Address, function string, params []byte) ([]byte, error) { return t.callContext.CallContract( t.Context, &CallInfo{ Contract: contract, State: t.StateManager, FunctionName: function, - Params: test.SerializeParams(params...), + Params: params, }) } @@ -220,10 +204,8 @@ func newTestRuntime(ctx context.Context) *testRuntime { NewConfig(), logging.NoLog{}).WithDefaults(CallInfo{Fuel: 1000000000}), StateManager: TestStateManager{ - ContractsMap: map[string][]byte{}, - AccountMap: map[codec.Address]string{}, - Balances: map[codec.Address]uint64{}, - Mu: test.NewTestDB(), + ContractManager: NewContractStateManager(test.NewTestDB(), []byte{}), + Balances: map[codec.Address]uint64{}, }, } } @@ -253,10 +235,15 @@ type testContract struct { } func (t *testContract) Call(function string, params ...interface{}) ([]byte, error) { + args := test.SerializeParams(params...) + return t.CallWithSerializedParams(function, args) +} + +func (t *testContract) CallWithSerializedParams(function string, params []byte) ([]byte, error) { return t.Runtime.CallContract( t.Address, function, - params...) + params) } func (t *testContract) WithStateManager(manager StateManager) *testContract { diff --git a/x/contracts/simulator/ffi/ffi.go b/x/contracts/simulator/ffi/ffi.go index 53d9451b40..ec2e2d177b 100644 --- a/x/contracts/simulator/ffi/ffi.go +++ b/x/contracts/simulator/ffi/ffi.go @@ -87,14 +87,15 @@ func CreateContract(db *C.Mutable, path *C.char) C.CreateContractResponse { } } - contractID, err := generateRandomID() + id, err := generateRandomID() if err != nil { return C.CreateContractResponse{ error: C.CString(err.Error()), } } - err = contractManager.SetContract(context.TODO(), contractID, contractBytes) + contractID := runtime.ContractID(id[:]) + err = contractManager.SetContractBytes(context.TODO(), contractID, contractBytes) if err != nil { errmsg := "contract creation failed: " + err.Error() return C.CreateContractResponse{ diff --git a/x/contracts/simulator/state/manager.go b/x/contracts/simulator/state/manager.go index f8c11c1784..76dd05917d 100644 --- a/x/contracts/simulator/state/manager.go +++ b/x/contracts/simulator/state/manager.go @@ -5,15 +5,12 @@ package state import ( "context" - "crypto/sha256" "encoding/binary" "errors" "github.com/ava-labs/avalanchego/database" - "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/hypersdk/codec" - "github.com/ava-labs/hypersdk/crypto/ed25519" "github.com/ava-labs/hypersdk/state" "github.com/ava-labs/hypersdk/x/contracts/runtime" ) @@ -21,27 +18,25 @@ import ( var _ runtime.StateManager = &ContractStateManager{} var ( - ErrUnknownAccount = errors.New("unknown account") - balanceKeyBytes = []byte("balance") - contractKeyBytes = []byte("contract") + balanceKeyBytes = []byte("balance") + contractManagerPrefix = []byte("contract") ) const ( - contractPrefix = 0x0 - - accountPrefix = 0x1 - accountDataPrefix = 0x0 - accountStatePrefix = 0x1 - - addressStoragePrefix = 0x3 + BalanceManagerPrefix = 0x1 ) type ContractStateManager struct { - db state.Mutable + db state.Mutable + contractState *runtime.ContractStateManager } func NewContractStateManager(db state.Mutable) *ContractStateManager { - return &ContractStateManager{db} + contractManager := runtime.NewContractStateManager(db, contractManagerPrefix) + return &ContractStateManager{ + db: db, + contractState: contractManager, + } } // GetBalance gets the balance associated [account]. @@ -83,110 +78,36 @@ func (p *ContractStateManager) TransferBalance(ctx context.Context, from codec.A return p.SetBalance(ctx, from, fromBalance-amount) } -func (p *ContractStateManager) GetContractState(account codec.Address) state.Mutable { - return newAccountPrefixedMutable(account, p.db) -} - -// GetAccountContract grabs the associated id with [account]. The ID is the key mapping to the contractbytes -// Errors if there is no found account or an error fetching -func (p *ContractStateManager) GetAccountContract(ctx context.Context, account codec.Address) (runtime.ContractID, error) { - contractID, exists, err := p.getAccountContract(ctx, account) - if err != nil { - return ids.Empty[:], err - } - if !exists { - return ids.Empty[:], ErrUnknownAccount - } - return contractID[:], nil -} - -// [contractID] -> [contractBytes] -func (p *ContractStateManager) GetContractBytes(ctx context.Context, contractID runtime.ContractID) ([]byte, error) { - // TODO: take fee out of balance? - contractBytes, err := p.db.GetValue(ctx, contractKey(contractID)) - if err != nil { - return []byte{}, ErrUnknownAccount - } - - return contractBytes, nil -} - -func (p *ContractStateManager) NewAccountWithContract(ctx context.Context, contractID runtime.ContractID, accountCreationData []byte) (codec.Address, error) { - newID := sha256.Sum256(append(contractID, accountCreationData...)) - newAccount := codec.CreateAddress(0, newID) - return newAccount, p.SetAccountContract(ctx, newAccount, contractID) -} - -func (p *ContractStateManager) SetAccountContract(ctx context.Context, account codec.Address, contractID runtime.ContractID) error { - return p.db.Insert(ctx, accountDataKey(account[:], contractKeyBytes), contractID) -} - // Creates an account balance key -func accountBalanceKey(account []byte) []byte { - return accountDataKey(account, balanceKeyBytes) -} - -func accountContractKey(account []byte) []byte { - return accountDataKey(account, contractKeyBytes) -} - -// Creates a key an account balance key -func accountDataKey(account []byte, key []byte) (k []byte) { - // accountPrefix + account + accountDataPrefix + key - k = make([]byte, 0, 2+len(account)+len(key)) - k = append(k, accountPrefix) +func accountBalanceKey(account []byte) (k []byte) { + k = make([]byte, 0, 1+len(account)+len(balanceKeyBytes)) + k = append(k, BalanceManagerPrefix) k = append(k, account...) - k = append(k, accountDataPrefix) - k = append(k, key...) + k = append(k, balanceKeyBytes...) return } -func contractKey(key []byte) (k []byte) { - k = make([]byte, 0, 1+len(key)) - k = append(k, contractPrefix) - k = append(k, key...) - return +// expose contract manager methods +func (p *ContractStateManager) GetContractState(address codec.Address) state.Mutable { + return p.contractState.GetContractState(address) } -func (p *ContractStateManager) getAccountContract(ctx context.Context, account codec.Address) (ids.ID, bool, error) { - v, err := p.db.GetValue(ctx, accountContractKey(account[:])) - if errors.Is(err, database.ErrNotFound) { - return ids.Empty, false, nil - } - if err != nil { - return ids.Empty, false, err - } - return ids.ID(v[:ids.IDLen]), true, nil +func (p *ContractStateManager) GetAccountContract(ctx context.Context, account codec.Address) (runtime.ContractID, error) { + return p.contractState.GetAccountContract(ctx, account) } -// setContract stores [contract] at [contractID] -func (p *ContractStateManager) SetContract( - ctx context.Context, - contractID ids.ID, - contract []byte, -) error { - return p.db.Insert(ctx, contractKey(contractID[:]), contract) +func (p *ContractStateManager) GetContractBytes(ctx context.Context, contractID runtime.ContractID) ([]byte, error) { + return p.contractState.GetContractBytes(ctx, contractID) } -// gets the public key mapped to the given name. -func GetPublicKey(ctx context.Context, db state.Immutable, name string) (ed25519.PublicKey, bool, error) { - k := make([]byte, 1+ed25519.PublicKeyLen) - k = append(k, addressStoragePrefix) - k = append(k, []byte(name)...) +func (p *ContractStateManager) NewAccountWithContract(ctx context.Context, contractID runtime.ContractID, accountCreationData []byte) (codec.Address, error) { + return p.contractState.NewAccountWithContract(ctx, contractID, accountCreationData) +} - v, err := db.GetValue(ctx, k) - if errors.Is(err, database.ErrNotFound) { - return ed25519.EmptyPublicKey, false, nil - } - if err != nil { - return ed25519.EmptyPublicKey, false, err - } - return ed25519.PublicKey(v), true, nil +func (p *ContractStateManager) SetAccountContract(ctx context.Context, account codec.Address, contractID runtime.ContractID) error { + return p.contractState.SetAccountContract(ctx, account, contractID) } -func SetKey(ctx context.Context, db state.Mutable, privateKey ed25519.PrivateKey, name string) error { - k := make([]byte, 1+ed25519.PublicKeyLen) - k = append(k, addressStoragePrefix) - k = append(k, []byte(name)...) - return db.Insert(ctx, k, privateKey[:]) +func (p *ContractStateManager) SetContractBytes(ctx context.Context, contractID runtime.ContractID, contractBytes []byte) error { + return p.contractState.SetContractBytes(ctx, contractID, contractBytes) } diff --git a/x/contracts/simulator/state/state.go b/x/contracts/simulator/state/state.go index 8081317bb3..a6ec8df713 100644 --- a/x/contracts/simulator/state/state.go +++ b/x/contracts/simulator/state/state.go @@ -17,7 +17,6 @@ import ( "github.com/ava-labs/avalanchego/database" - "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/state" ) @@ -99,39 +98,3 @@ func (s *Mutable) Remove(_ context.Context, key []byte) error { return nil } - -type prefixedStateMutable struct { - inner state.Mutable - prefix []byte -} - -func (s *prefixedStateMutable) prefixKey(key []byte) (k []byte) { - k = make([]byte, len(s.prefix)+len(key)) - copy(k, s.prefix) - copy(k[len(s.prefix):], key) - return -} - -func (s *prefixedStateMutable) GetValue(ctx context.Context, key []byte) (value []byte, err error) { - return s.inner.GetValue(ctx, s.prefixKey(key)) -} - -func (s *prefixedStateMutable) Insert(ctx context.Context, key []byte, value []byte) error { - return s.inner.Insert(ctx, s.prefixKey(key), value) -} - -func (s *prefixedStateMutable) Remove(ctx context.Context, key []byte) error { - return s.inner.Remove(ctx, s.prefixKey(key)) -} - -func newAccountPrefixedMutable(account codec.Address, mutable state.Mutable) state.Mutable { - return &prefixedStateMutable{inner: mutable, prefix: accountStateKey(account[:])} -} - -func accountStateKey(key []byte) (k []byte) { - k = make([]byte, 2+len(key)) - k[0] = accountPrefix - copy(k[1:], key) - k[len(k)-1] = accountStatePrefix - return -} diff --git a/examples/vmwithcontracts/.golangci.yml b/x/contracts/vm/.golangci.yml similarity index 100% rename from examples/vmwithcontracts/.golangci.yml rename to x/contracts/vm/.golangci.yml diff --git a/examples/vmwithcontracts/.goreleaser.yml b/x/contracts/vm/.goreleaser.yml similarity index 100% rename from examples/vmwithcontracts/.goreleaser.yml rename to x/contracts/vm/.goreleaser.yml diff --git a/examples/vmwithcontracts/LICENSE b/x/contracts/vm/LICENSE similarity index 100% rename from examples/vmwithcontracts/LICENSE rename to x/contracts/vm/LICENSE diff --git a/examples/vmwithcontracts/actions/call.go b/x/contracts/vm/actions/call.go similarity index 87% rename from examples/vmwithcontracts/actions/call.go rename to x/contracts/vm/actions/call.go index 2aa36fd404..348f8bf0eb 100644 --- a/examples/vmwithcontracts/actions/call.go +++ b/x/contracts/vm/actions/call.go @@ -12,16 +12,19 @@ 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/vmwithcontracts/storage" "github.com/ava-labs/hypersdk/state" "github.com/ava-labs/hypersdk/x/contracts/runtime" + "github.com/ava-labs/hypersdk/x/contracts/vm/storage" - mconsts "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" + mconsts "github.com/ava-labs/hypersdk/x/contracts/vm/consts" ) 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/actions/call_test.go b/x/contracts/vm/actions/call_test.go similarity index 100% rename from examples/vmwithcontracts/actions/call_test.go rename to x/contracts/vm/actions/call_test.go diff --git a/examples/vmwithcontracts/actions/deploy.go b/x/contracts/vm/actions/deploy.go similarity index 94% rename from examples/vmwithcontracts/actions/deploy.go rename to x/contracts/vm/actions/deploy.go index a53a677c20..3a3f6e5a3b 100644 --- a/examples/vmwithcontracts/actions/deploy.go +++ b/x/contracts/vm/actions/deploy.go @@ -11,12 +11,12 @@ import ( "github.com/ava-labs/hypersdk/chain" "github.com/ava-labs/hypersdk/codec" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/storage" "github.com/ava-labs/hypersdk/keys" "github.com/ava-labs/hypersdk/state" "github.com/ava-labs/hypersdk/x/contracts/runtime" + "github.com/ava-labs/hypersdk/x/contracts/vm/storage" - mconsts "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" + mconsts "github.com/ava-labs/hypersdk/x/contracts/vm/consts" ) var _ chain.Action = (*Deploy)(nil) diff --git a/examples/vmwithcontracts/actions/publish.go b/x/contracts/vm/actions/publish.go similarity index 90% rename from examples/vmwithcontracts/actions/publish.go rename to x/contracts/vm/actions/publish.go index 1b664e8272..80f976819b 100644 --- a/examples/vmwithcontracts/actions/publish.go +++ b/x/contracts/vm/actions/publish.go @@ -12,12 +12,12 @@ import ( "github.com/ava-labs/hypersdk/chain" "github.com/ava-labs/hypersdk/codec" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/storage" "github.com/ava-labs/hypersdk/keys" "github.com/ava-labs/hypersdk/state" "github.com/ava-labs/hypersdk/x/contracts/runtime" + "github.com/ava-labs/hypersdk/x/contracts/vm/storage" - mconsts "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" + mconsts "github.com/ava-labs/hypersdk/x/contracts/vm/consts" ) var _ chain.Action = (*Publish)(nil) @@ -33,7 +33,7 @@ func (*Publish) GetTypeID() uint8 { return mconsts.PublishID } -func (t *Publish) StateKeys(_ codec.Address) state.Keys { +func (t *Publish) StateKeys(_ codec.Address, _ ids.ID) state.Keys { if t.id == nil { hashedID := sha256.Sum256(t.ContractBytes) t.id, _ = keys.Encode(storage.ContractsKey(hashedID[:]), len(t.ContractBytes)) diff --git a/examples/vmwithcontracts/actions/transfer.go b/x/contracts/vm/actions/transfer.go similarity index 95% rename from examples/vmwithcontracts/actions/transfer.go rename to x/contracts/vm/actions/transfer.go index 44398f0b96..1d01fb0387 100644 --- a/examples/vmwithcontracts/actions/transfer.go +++ b/x/contracts/vm/actions/transfer.go @@ -12,10 +12,10 @@ 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/vmwithcontracts/storage" "github.com/ava-labs/hypersdk/state" + "github.com/ava-labs/hypersdk/x/contracts/vm/storage" - mconsts "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" + mconsts "github.com/ava-labs/hypersdk/x/contracts/vm/consts" ) const ( diff --git a/examples/vmwithcontracts/actions/transfer_test.go b/x/contracts/vm/actions/transfer_test.go similarity index 98% rename from examples/vmwithcontracts/actions/transfer_test.go rename to x/contracts/vm/actions/transfer_test.go index 2b016fa3a1..b822423162 100644 --- a/examples/vmwithcontracts/actions/transfer_test.go +++ b/x/contracts/vm/actions/transfer_test.go @@ -13,9 +13,9 @@ import ( "github.com/ava-labs/hypersdk/chain/chaintest" "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/codec/codectest" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/storage" "github.com/ava-labs/hypersdk/state" "github.com/ava-labs/hypersdk/state/tstate" + "github.com/ava-labs/hypersdk/x/contracts/vm/storage" ) func TestTransferAction(t *testing.T) { diff --git a/examples/vmwithcontracts/assets/hypersdk.png b/x/contracts/vm/assets/hypersdk.png similarity index 100% rename from examples/vmwithcontracts/assets/hypersdk.png rename to x/contracts/vm/assets/hypersdk.png diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/action.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/action.go similarity index 82% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/action.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/action.go index 3c0f1ffe0a..3ce89300b1 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/action.go +++ b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/action.go @@ -5,6 +5,8 @@ package cmd import ( "context" + "errors" + "fmt" "os" "github.com/near/borsh-go" @@ -14,10 +16,13 @@ import ( "github.com/ava-labs/hypersdk/chain" "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/utils" + "github.com/ava-labs/hypersdk/x/contracts/vm/actions" + "github.com/ava-labs/hypersdk/x/contracts/vm/vm" ) +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/chain.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/chain.go similarity index 100% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/chain.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/chain.go diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/errors.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/errors.go similarity index 100% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/errors.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/errors.go diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/genesis.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/genesis.go similarity index 100% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/genesis.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/genesis.go diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/handler.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/handler.go similarity index 92% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/handler.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/handler.go index 142ec89b00..633049ebcf 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/handler.go +++ b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/handler.go @@ -17,10 +17,12 @@ import ( "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/consts" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/vm" "github.com/ava-labs/hypersdk/pubsub" "github.com/ava-labs/hypersdk/utils" + "github.com/ava-labs/hypersdk/x/contracts/vm/consts" + "github.com/ava-labs/hypersdk/x/contracts/vm/vm" + + hconsts "github.com/ava-labs/hypersdk/consts" ) var _ cli.Controller = (*Controller)(nil) @@ -38,7 +40,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 +75,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, @@ -122,7 +124,7 @@ func (*Controller) Symbol() string { } func (*Controller) Decimals() uint8 { - return consts.Decimals + return hconsts.Decimals } func (*Controller) GetParser(uri string) (chain.Parser, error) { diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/key.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/key.go similarity index 91% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/key.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/key.go index 000c092b31..950d0cb166 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/key.go +++ b/x/contracts/vm/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/prometheus.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/prometheus.go similarity index 100% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/prometheus.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/prometheus.go diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/resolutions.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/resolutions.go similarity index 92% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/resolutions.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/resolutions.go index c691a0c3bf..0f9db49dea 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/resolutions.go +++ b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/resolutions.go @@ -11,10 +11,10 @@ import ( "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/examples/vmwithcontracts/actions" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/vm" "github.com/ava-labs/hypersdk/utils" + "github.com/ava-labs/hypersdk/x/contracts/vm/actions" + "github.com/ava-labs/hypersdk/x/contracts/vm/consts" + "github.com/ava-labs/hypersdk/x/contracts/vm/vm" ) // sendAndWait may not be used concurrently diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/root.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/root.go similarity index 100% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/root.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/root.go diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/spam.go b/x/contracts/vm/cmd/vmwithcontracts-cli/cmd/spam.go similarity index 56% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/spam.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/cmd/spam.go index 4e6e53b8e2..21731fbb21 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd/spam.go +++ b/x/contracts/vm/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" + "github.com/ava-labs/hypersdk/x/contracts/vm/actions" + "github.com/ava-labs/hypersdk/x/contracts/vm/vm" ) 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/cmd/vmwithcontracts-cli/main.go b/x/contracts/vm/cmd/vmwithcontracts-cli/main.go similarity index 83% rename from examples/vmwithcontracts/cmd/vmwithcontracts-cli/main.go rename to x/contracts/vm/cmd/vmwithcontracts-cli/main.go index c2be6a7460..54bbf9e985 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts-cli/main.go +++ b/x/contracts/vm/cmd/vmwithcontracts-cli/main.go @@ -7,8 +7,8 @@ package main import ( "os" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/cmd/vmwithcontracts-cli/cmd" "github.com/ava-labs/hypersdk/utils" + "github.com/ava-labs/hypersdk/x/contracts/vm/cmd/vmwithcontracts-cli/cmd" ) func main() { diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts/main.go b/x/contracts/vm/cmd/vmwithcontracts/main.go similarity index 87% rename from examples/vmwithcontracts/cmd/vmwithcontracts/main.go rename to x/contracts/vm/cmd/vmwithcontracts/main.go index 62dfbc2a2a..2d92804095 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts/main.go +++ b/x/contracts/vm/cmd/vmwithcontracts/main.go @@ -13,8 +13,8 @@ import ( "github.com/ava-labs/avalanchego/vms/rpcchainvm" "github.com/spf13/cobra" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/cmd/vmwithcontracts/version" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/vm" + "github.com/ava-labs/hypersdk/x/contracts/vm/cmd/vmwithcontracts/version" + "github.com/ava-labs/hypersdk/x/contracts/vm/vm" ) var rootCmd = &cobra.Command{ diff --git a/examples/vmwithcontracts/cmd/vmwithcontracts/version/version.go b/x/contracts/vm/cmd/vmwithcontracts/version/version.go similarity index 89% rename from examples/vmwithcontracts/cmd/vmwithcontracts/version/version.go rename to x/contracts/vm/cmd/vmwithcontracts/version/version.go index 48cc4008d0..c3fa1e6791 100644 --- a/examples/vmwithcontracts/cmd/vmwithcontracts/version/version.go +++ b/x/contracts/vm/cmd/vmwithcontracts/version/version.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" + "github.com/ava-labs/hypersdk/x/contracts/vm/consts" ) func init() { diff --git a/examples/vmwithcontracts/consts/consts.go b/x/contracts/vm/consts/consts.go similarity index 83% rename from examples/vmwithcontracts/consts/consts.go rename to x/contracts/vm/consts/consts.go index 88d4a399c8..c90d8a183b 100644 --- a/examples/vmwithcontracts/consts/consts.go +++ b/x/contracts/vm/consts/consts.go @@ -9,10 +9,8 @@ import ( ) const ( - HRP = "contracts" - Name = "vmwithcontracts" - Symbol = "RED" - Decimals = 9 + Name = "vmwithcontracts" + Symbol = "RED" ) var ID ids.ID diff --git a/examples/vmwithcontracts/consts/types.go b/x/contracts/vm/consts/types.go similarity index 100% rename from examples/vmwithcontracts/consts/types.go rename to x/contracts/vm/consts/types.go diff --git a/examples/vmwithcontracts/go.mod b/x/contracts/vm/go.mod similarity index 98% rename from examples/vmwithcontracts/go.mod rename to x/contracts/vm/go.mod index d2814207f9..fecff1b72e 100644 --- a/examples/vmwithcontracts/go.mod +++ b/x/contracts/vm/go.mod @@ -1,6 +1,6 @@ -module github.com/ava-labs/hypersdk/examples/vmwithcontracts +module github.com/ava-labs/hypersdk/x/contracts/vm -go 1.21.12 +go 1.22.8 require ( github.com/ava-labs/avalanchego v1.11.12-rc.2.0.20241001202925-f03745d187d0 @@ -155,4 +155,4 @@ require ( rsc.io/tmplfunc v0.0.3 // indirect ) -replace github.com/ava-labs/hypersdk => ../../ +replace github.com/ava-labs/hypersdk => ../../../ diff --git a/examples/vmwithcontracts/go.sum b/x/contracts/vm/go.sum similarity index 100% rename from examples/vmwithcontracts/go.sum rename to x/contracts/vm/go.sum diff --git a/examples/vmwithcontracts/scripts/build.release.sh b/x/contracts/vm/scripts/build.release.sh similarity index 100% rename from examples/vmwithcontracts/scripts/build.release.sh rename to x/contracts/vm/scripts/build.release.sh diff --git a/examples/vmwithcontracts/scripts/build.sh b/x/contracts/vm/scripts/build.sh similarity index 100% rename from examples/vmwithcontracts/scripts/build.sh rename to x/contracts/vm/scripts/build.sh diff --git a/examples/vmwithcontracts/scripts/fix.lint.sh b/x/contracts/vm/scripts/fix.lint.sh similarity index 100% rename from examples/vmwithcontracts/scripts/fix.lint.sh rename to x/contracts/vm/scripts/fix.lint.sh diff --git a/examples/vmwithcontracts/scripts/lint.sh b/x/contracts/vm/scripts/lint.sh similarity index 100% rename from examples/vmwithcontracts/scripts/lint.sh rename to x/contracts/vm/scripts/lint.sh diff --git a/examples/vmwithcontracts/scripts/run.sh b/x/contracts/vm/scripts/run.sh similarity index 100% rename from examples/vmwithcontracts/scripts/run.sh rename to x/contracts/vm/scripts/run.sh diff --git a/examples/vmwithcontracts/scripts/stop.sh b/x/contracts/vm/scripts/stop.sh similarity index 100% rename from examples/vmwithcontracts/scripts/stop.sh rename to x/contracts/vm/scripts/stop.sh diff --git a/examples/vmwithcontracts/scripts/tests.integration.sh b/x/contracts/vm/scripts/tests.integration.sh similarity index 100% rename from examples/vmwithcontracts/scripts/tests.integration.sh rename to x/contracts/vm/scripts/tests.integration.sh diff --git a/examples/vmwithcontracts/scripts/tests.unit.sh b/x/contracts/vm/scripts/tests.unit.sh similarity index 100% rename from examples/vmwithcontracts/scripts/tests.unit.sh rename to x/contracts/vm/scripts/tests.unit.sh diff --git a/examples/vmwithcontracts/storage/errors.go b/x/contracts/vm/storage/errors.go similarity index 100% rename from examples/vmwithcontracts/storage/errors.go rename to x/contracts/vm/storage/errors.go diff --git a/examples/vmwithcontracts/storage/programs.go b/x/contracts/vm/storage/programs.go similarity index 100% rename from examples/vmwithcontracts/storage/programs.go rename to x/contracts/vm/storage/programs.go diff --git a/examples/vmwithcontracts/storage/state_manager.go b/x/contracts/vm/storage/state_manager.go similarity index 67% rename from examples/vmwithcontracts/storage/state_manager.go rename to x/contracts/vm/storage/state_manager.go index 630481a916..c5d9cbc8cd 100644 --- a/examples/vmwithcontracts/storage/state_manager.go +++ b/x/contracts/vm/storage/state_manager.go @@ -11,29 +11,17 @@ import ( "github.com/ava-labs/hypersdk/state" ) -var _ (chain.StateManager) = (*StateManager)(nil) +var _ (chain.BalanceHandler) = (*BalanceHandler)(nil) -type StateManager struct{} +type BalanceHandler struct{} -func (*StateManager) HeightKey() []byte { - return HeightKey() -} - -func (*StateManager) TimestampKey() []byte { - return TimestampKey() -} - -func (*StateManager) FeeKey() []byte { - return FeeKey() -} - -func (*StateManager) SponsorStateKeys(addr codec.Address) state.Keys { +func (*BalanceHandler) SponsorStateKeys(addr codec.Address) state.Keys { return state.Keys{ string(BalanceKey(addr)): state.Read | state.Write, } } -func (*StateManager) CanDeduct( +func (*BalanceHandler) CanDeduct( ctx context.Context, addr codec.Address, im state.Immutable, @@ -49,7 +37,7 @@ func (*StateManager) CanDeduct( return nil } -func (*StateManager) Deduct( +func (*BalanceHandler) Deduct( ctx context.Context, addr codec.Address, mu state.Mutable, @@ -59,7 +47,7 @@ func (*StateManager) Deduct( return err } -func (*StateManager) AddBalance( +func (*BalanceHandler) AddBalance( ctx context.Context, addr codec.Address, mu state.Mutable, @@ -69,3 +57,7 @@ func (*StateManager) AddBalance( _, err := AddBalance(ctx, mu, addr, amount, createAccount) return err } + +func (*BalanceHandler) GetBalance(ctx context.Context, addr codec.Address, im state.Immutable) (uint64, error) { + return GetBalance(ctx, im, addr) +} diff --git a/examples/vmwithcontracts/storage/storage.go b/x/contracts/vm/storage/storage.go similarity index 87% rename from examples/vmwithcontracts/storage/storage.go rename to x/contracts/vm/storage/storage.go index 314057f621..03aeb6df93 100644 --- a/examples/vmwithcontracts/storage/storage.go +++ b/x/contracts/vm/storage/storage.go @@ -14,6 +14,7 @@ import ( "github.com/ava-labs/hypersdk/codec" "github.com/ava-labs/hypersdk/consts" "github.com/ava-labs/hypersdk/state" + "github.com/ava-labs/hypersdk/state/metadata" smath "github.com/ava-labs/avalanchego/utils/math" ) @@ -23,24 +24,20 @@ type ReadState func(context.Context, [][]byte) ([][]byte, []error) // State // / (height) => store in root // -> [heightPrefix] => height -// 0x0/ (balance) +// 0x0/ (hypersdk-height) +// 0x1/ (hypersdk-timestamp) +// 0x2/ (hypersdk-fee) +// 0x3/ (balance) // -> [owner] => balance -// 0x1/ (hypersdk-height) -// 0x2/ (hypersdk-timestamp) -// 0x3/ (hypersdk-fee) // 0x4/ (account-storage) // 0x4/address/0x1 (address associated contract) // 0x4/address/0x1 (address associated state) // 0x5/ (contracts-storage) const ( - // Active state - balancePrefix = 0x0 - heightPrefix = 0x1 - timestampPrefix = 0x2 - feePrefix = 0x3 - accountsPrefix = 0x4 - contractsPrefix = 0x5 + balancePrefix byte = metadata.DefaultMinimumPrefix + iota + accountsPrefix + contractsPrefix accountContractPrefix = 0x0 accountStatePrefix = 0x1 @@ -48,12 +45,6 @@ const ( const BalanceChunks uint16 = 1 -var ( - heightKey = []byte{heightPrefix} - timestampKey = []byte{timestampPrefix} - feeKey = []byte{feePrefix} -) - // [balancePrefix] + [address] func BalanceKey(addr codec.Address) (k []byte) { k = make([]byte, 1+codec.AddressLen+consts.Uint16Len) @@ -190,15 +181,3 @@ func SubBalance( } return nbal, setBalance(ctx, mu, key, nbal) } - -func HeightKey() (k []byte) { - return heightKey -} - -func TimestampKey() (k []byte) { - return timestampKey -} - -func FeeKey() (k []byte) { - return feeKey -} diff --git a/examples/vmwithcontracts/tests/e2e/e2e_test.go b/x/contracts/vm/tests/e2e/e2e_test.go similarity index 82% rename from examples/vmwithcontracts/tests/e2e/e2e_test.go rename to x/contracts/vm/tests/e2e/e2e_test.go index 6cfcbb19d7..0f69e86bd9 100644 --- a/examples/vmwithcontracts/tests/e2e/e2e_test.go +++ b/x/contracts/vm/tests/e2e/e2e_test.go @@ -11,10 +11,10 @@ import ( "github.com/stretchr/testify/require" "github.com/ava-labs/hypersdk/abi" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/tests/workload" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/vm" "github.com/ava-labs/hypersdk/tests/fixture" + "github.com/ava-labs/hypersdk/x/contracts/vm/consts" + "github.com/ava-labs/hypersdk/x/contracts/vm/tests/workload" + "github.com/ava-labs/hypersdk/x/contracts/vm/vm" he2e "github.com/ava-labs/hypersdk/tests/e2e" ginkgo "github.com/onsi/ginkgo/v2" @@ -25,7 +25,7 @@ const owner = "vmwithcontracts-e2e-tests" var flagVars *e2e.FlagVars func TestE2e(t *testing.T) { - ginkgo.RunSpecs(t, "vmwithcontracts e2e test suites") + // ginkgo.RunSpecs(t, "vmwithcontracts e2e test suites") } func init() { @@ -50,7 +50,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/tests/integration/integration_test.go b/x/contracts/vm/tests/integration/integration_test.go similarity index 82% rename from examples/vmwithcontracts/tests/integration/integration_test.go rename to x/contracts/vm/tests/integration/integration_test.go index bd9a4350da..914f8d9811 100644 --- a/examples/vmwithcontracts/tests/integration/integration_test.go +++ b/x/contracts/vm/tests/integration/integration_test.go @@ -11,11 +11,11 @@ import ( "github.com/ava-labs/hypersdk/auth" "github.com/ava-labs/hypersdk/crypto/ed25519" - "github.com/ava-labs/hypersdk/examples/vmwithcontracts/vm" "github.com/ava-labs/hypersdk/tests/integration" + "github.com/ava-labs/hypersdk/x/contracts/vm/vm" - lconsts "github.com/ava-labs/hypersdk/examples/vmwithcontracts/consts" - vmwithcontractsWorkload "github.com/ava-labs/hypersdk/examples/vmwithcontracts/tests/workload" + lconsts "github.com/ava-labs/hypersdk/x/contracts/vm/consts" + vmwithcontractsWorkload "github.com/ava-labs/hypersdk/x/contracts/vm/tests/workload" ginkgo "github.com/onsi/ginkgo/v2" ) diff --git a/examples/vmwithcontracts/tests/workload/workload.go b/x/contracts/vm/tests/workload/workload.go similarity index 96% rename from examples/vmwithcontracts/tests/workload/workload.go rename to x/contracts/vm/tests/workload/workload.go index eaf330fc99..d5d8a9f8e1 100644 --- a/examples/vmwithcontracts/tests/workload/workload.go +++ b/x/contracts/vm/tests/workload/workload.go @@ -19,11 +19,11 @@ import ( "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/vm" "github.com/ava-labs/hypersdk/fees" "github.com/ava-labs/hypersdk/genesis" "github.com/ava-labs/hypersdk/tests/workload" + "github.com/ava-labs/hypersdk/x/contracts/vm/actions" + "github.com/ava-labs/hypersdk/x/contracts/vm/vm" ) const ( @@ -85,6 +85,13 @@ func New(minBlockGap int64) (*genesis.DefaultGenesis, workload.TxWorkloadFactory }, nil } +func (*workloadFactory) GetSpendingKey() (*auth.PrivateKey, error) { + return &auth.PrivateKey{ + Address: ed25519Addrs[0], + Bytes: ed25519PrivKeys[0][:], + }, nil +} + func (f *workloadFactory) NewSizedTxWorkload(uri string, size int) (workload.TxWorkloadIterator, error) { cli := jsonrpc.NewJSONRPCClient(uri) lcli := vm.NewJSONRPCClient(uri) diff --git a/examples/vmwithcontracts/vm/client.go b/x/contracts/vm/vm/client.go similarity index 70% rename from examples/vmwithcontracts/vm/client.go rename to x/contracts/vm/vm/client.go index c75e8e821f..4cf4812a35 100644 --- a/examples/vmwithcontracts/vm/client.go +++ b/x/contracts/vm/vm/client.go @@ -5,7 +5,6 @@ package vm import ( "context" - "encoding/hex" "encoding/json" "strings" "time" @@ -13,13 +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" + "github.com/ava-labs/hypersdk/x/contracts/vm/consts" ) const balanceCheckInterval = 500 * time.Millisecond @@ -109,22 +105,18 @@ func (p *Parser) Rules(_ int64) chain.Rules { return p.genesis.Rules } -func (*Parser) ActionRegistry() chain.ActionRegistry { +func (*Parser) ActionCodec() *codec.TypeParser[chain.Action] { return ActionParser } -func (*Parser) OutputRegistry() chain.OutputRegistry { +func (*Parser) OutputCodec() *codec.TypeParser[codec.Typed] { return OutputParser } -func (*Parser) AuthRegistry() chain.AuthRegistry { +func (*Parser) AuthCodec() *codec.TypeParser[chain.Auth] { return AuthParser } -func (*Parser) StateManager() chain.StateManager { - return &storage.StateManager{} -} - func NewParser(genesis *genesis.DefaultGenesis) chain.Parser { return &Parser{genesis: genesis} } @@ -137,26 +129,3 @@ func CreateParser(genesisBytes []byte) (chain.Parser, error) { } return NewParser(&genesis), 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/option.go b/x/contracts/vm/vm/option.go similarity index 100% rename from examples/vmwithcontracts/vm/option.go rename to x/contracts/vm/vm/option.go diff --git a/x/contracts/vm/vm/server.go b/x/contracts/vm/vm/server.go new file mode 100644 index 0000000000..9596f52816 --- /dev/null +++ b/x/contracts/vm/vm/server.go @@ -0,0 +1,65 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package vm + +import ( + "net/http" + + "github.com/ava-labs/hypersdk/api" + "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/genesis" + "github.com/ava-labs/hypersdk/x/contracts/vm/consts" + "github.com/ava-labs/hypersdk/x/contracts/vm/storage" +) + +const JSONRPCEndpoint = "/vmwithcontractsapi" + +var _ api.HandlerFactory[api.VM] = (*jsonRPCServerFactory)(nil) + +type jsonRPCServerFactory struct{} + +func (jsonRPCServerFactory) New(v api.VM) (api.Handler, error) { + handler, err := api.NewJSONRPCHandler(consts.Name, NewJSONRPCServer(v)) + return api.Handler{ + Path: JSONRPCEndpoint, + Handler: handler, + }, err +} + +type JSONRPCServer struct { + vm api.VM +} + +func NewJSONRPCServer(vm api.VM) *JSONRPCServer { + return &JSONRPCServer{vm: vm} +} + +type GenesisReply struct { + Genesis *genesis.DefaultGenesis `json:"genesis"` +} + +func (j *JSONRPCServer) Genesis(_ *http.Request, _ *struct{}, reply *GenesisReply) (err error) { + reply.Genesis = j.vm.Genesis().(*genesis.DefaultGenesis) + return nil +} + +type BalanceArgs struct { + Address codec.Address `json:"address"` +} + +type BalanceReply struct { + Amount uint64 `json:"amount"` +} + +func (j *JSONRPCServer) Balance(req *http.Request, args *BalanceArgs, reply *BalanceReply) error { + ctx, span := j.vm.Tracer().Start(req.Context(), "Server.Balance") + defer span.End() + + balance, err := storage.GetBalanceFromState(ctx, j.vm.ReadState, args.Address) + if err != nil { + return err + } + reply.Amount = balance + return err +} diff --git a/examples/vmwithcontracts/vm/vm.go b/x/contracts/vm/vm/vm.go similarity index 90% rename from examples/vmwithcontracts/vm/vm.go rename to x/contracts/vm/vm/vm.go index 3aadbf7e30..111de426a9 100644 --- a/examples/vmwithcontracts/vm/vm.go +++ b/x/contracts/vm/vm/vm.go @@ -12,13 +12,14 @@ import ( "github.com/ava-labs/hypersdk/auth" "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/extension/externalsubscriber" "github.com/ava-labs/hypersdk/genesis" + "github.com/ava-labs/hypersdk/state/metadata" "github.com/ava-labs/hypersdk/vm" "github.com/ava-labs/hypersdk/x/contracts/runtime" + "github.com/ava-labs/hypersdk/x/contracts/vm/actions" + "github.com/ava-labs/hypersdk/x/contracts/vm/consts" + "github.com/ava-labs/hypersdk/x/contracts/vm/storage" ) var ( @@ -77,7 +78,8 @@ func NewWithOptions(options ...vm.Option) (*vm.VM, error) { return vm.New( consts.Version, genesis.DefaultGenesisFactory{}, - &storage.StateManager{}, + &storage.BalanceHandler{}, + metadata.NewDefaultManager(), ActionParser, AuthParser, OutputParser, diff --git a/x/contracts/wasmlanche/src/context.rs b/x/contracts/wasmlanche/src/context.rs index 6860e53724..38bd15fae9 100644 --- a/x/contracts/wasmlanche/src/context.rs +++ b/x/contracts/wasmlanche/src/context.rs @@ -264,10 +264,10 @@ impl Context { #[cfg(feature = "test")] impl Context { #[must_use] - pub fn new() -> Self { + pub fn with_actor(actor: Address) -> Self { Self { contract_address: Address::default(), - actor: Address::default(), + actor, height: 0, timestamp: 0, action_id: Id::default(), @@ -399,10 +399,3 @@ mod external { } } } - -#[cfg(feature = "test")] -impl Default for Context { - fn default() -> Self { - Self::new() - } -} diff --git a/x/contracts/wasmlanche/src/simulator.rs b/x/contracts/wasmlanche/src/simulator.rs index 33633a5d75..e6e271ee99 100644 --- a/x/contracts/wasmlanche/src/simulator.rs +++ b/x/contracts/wasmlanche/src/simulator.rs @@ -266,16 +266,14 @@ mod tests { #[test] fn get_balance() { - let account_data_prefix = [0x00]; - let account_prefix = [0x01]; + let balance_manager_prefix = [0x01]; let alice = Address::new([1; 33]); let mut state = SimpleState::new(); let exptected_balance = 999u64; - let key = account_prefix + let key = balance_manager_prefix .into_iter() .chain(alice.as_ref().iter().copied()) - .chain(account_data_prefix) .chain(b"balance".iter().copied()) .collect(); diff --git a/x/contracts/wasmlanche/src/types.rs b/x/contracts/wasmlanche/src/types.rs index 61d4b2f092..d8b48bc6c0 100644 --- a/x/contracts/wasmlanche/src/types.rs +++ b/x/contracts/wasmlanche/src/types.rs @@ -38,6 +38,8 @@ unsafe impl Pod for Address {} impl Address { pub const LEN: usize = size_of::(); + pub const ZERO: Self = Self([0; Self::LEN]); + // Constructor function for Address #[must_use] pub fn new(bytes: [u8; Self::LEN]) -> Self { diff --git a/x/contracts/wasmlanche/tests/test-crate/src/lib.rs b/x/contracts/wasmlanche/tests/test-crate/src/lib.rs index ecd31b477f..98220a175c 100644 --- a/x/contracts/wasmlanche/tests/test-crate/src/lib.rs +++ b/x/contracts/wasmlanche/tests/test-crate/src/lib.rs @@ -70,8 +70,8 @@ mod tests { #[test] fn test_balance() { - let mut context = Context::new(); let address = Address::default(); + let mut context = Context::with_actor(address); let amount: u64 = 100; // set the balance