diff --git a/.gitignore b/.gitignore index 0b93b2e2..b5ef81d9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ config.json /vendor/ .idea /bin/ + +# debhelpers +**/.debhelper diff --git a/Makefile b/Makefile index 22d0dc0c..5621f8d4 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,16 @@ export GOBIN ?= $(shell pwd)/bin NEOGO ?= $(GOBIN)/cli VERSION ?= $(shell git describe --tags --dirty --match "v*" --always --abbrev=8 2>/dev/null || cat VERSION 2>/dev/null || echo "develop") + +# .deb package versioning +OS_RELEASE = $(shell lsb_release -cs) +PKG_VERSION ?= $(shell echo $(VERSION) | sed "s/^v//" | \ + sed -E "s/(.*)-(g[a-fA-F0-9]{6,8})(.*)/\1\3~\2/" | \ + sed "s/-/~/")-${OS_RELEASE} + .PHONY: all build clean test neo-go .PHONY: alphabet mainnet morph nns sidechain +.PHONY: debpackage debclean build: neo-go all all: sidechain mainnet sidechain: alphabet morph nns @@ -58,3 +66,15 @@ archive: build @tar --transform "s|^./|neofs-contract-$(VERSION)/|" \ -czf neofs-contract-$(VERSION).tar.gz \ $(shell find . -name '*.nef' -o -name 'config.json') + +# Package for Debian +debpackage: + dch --package neofs-contract \ + --controlmaint \ + --newversion $(PKG_VERSION) \ + --distribution $(OS_RELEASE) \ + "Please see CHANGELOG.md for code changes for $(VERSION)" + dpkg-buildpackage --no-sign -b + +debclean: + dh clean diff --git a/README.md b/README.md index a79e2693..a8a89b7a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,16 @@ $ NEOGO=/home/user/neo-go/bin/neo-go make all Remove compiled files with `make clean` or `make mr_proper` command. +## Building Debian package + +To build Debian package containing compiled contracts, run `make debpackage` +command. Package will install compiled contracts `*_contract.nef` and manifest +`config.json` with corresponding directories to `/var/lib/neofs/contract` for +further usage. +It will download and build neo-go, if needed. + +To clean package-related files, use `make debclean`. + # Testing Smartcontract tests reside in `tests/` directory. To execute test suite after applying changes, simply run `make test`. diff --git a/container/config.yml b/container/config.yml index 71f47f5b..c4dc442a 100644 --- a/container/config.yml +++ b/container/config.yml @@ -1,5 +1,5 @@ name: "NeoFS Container" -safemethods: ["count", "get", "owner", "list", "eACL", "getContainerSize", "listContainerSizes", "version"] +safemethods: ["count", "containersOf", "get", "owner", "list", "eACL", "getContainerSize", "listContainerSizes", "iterateContainerSizes", "version"] permissions: - methods: ["update", "addKey", "transferX", "register", "addRecord", "deleteRecords"] diff --git a/container/container_contract.go b/container/container_contract.go index c3721238..bc8d130b 100644 --- a/container/container_contract.go +++ b/container/container_contract.go @@ -62,6 +62,8 @@ const ( singleEstimatePrefix = "est" estimateKeyPrefix = "cnr" + containerKeyPrefix = 'x' + ownerKeyPrefix = 'o' estimatePostfixSize = 10 // CleanupDelta contains the number of the last epochs for which container estimations are present. CleanupDelta = 3 @@ -74,10 +76,10 @@ const ( NotFoundError = "container does not exist" // default SOA record field values - defaultRefresh = 3600 // 1 hour - defaultRetry = 600 // 10 min - defaultExpire = 604800 // 1 week - defaultTTL = 3600 // 1 hour + defaultRefresh = 3600 // 1 hour + defaultRetry = 600 // 10 min + defaultExpire = 3600 * 24 * 365 * 10 // 10 years + defaultTTL = 3600 // 1 hour ) var ( @@ -93,6 +95,26 @@ func _deploy(data interface{}, isUpdate bool) { if isUpdate { args := data.([]interface{}) common.CheckVersion(args[len(args)-1].(int)) + + it := storage.Find(ctx, []byte{}, storage.None) + for iterator.Next(it) { + item := iterator.Value(it).(struct { + key []byte + value []byte + }) + + // Migrate container. + if len(item.key) == containerIDSize { + storage.Delete(ctx, item.key) + storage.Put(ctx, append([]byte{containerKeyPrefix}, item.key...), item.value) + } + + // Migrate owner-cid map. + if len(item.key) == 25 /* owner id size */ +containerIDSize { + storage.Delete(ctx, item.key) + storage.Put(ctx, append([]byte{ownerKeyPrefix}, item.key...), item.value) + } + } return } @@ -391,17 +413,24 @@ func Owner(containerID []byte) []byte { func Count() int { count := 0 ctx := storage.GetReadOnlyContext() - it := storage.Find(ctx, []byte{}, storage.KeysOnly) + it := storage.Find(ctx, []byte{containerKeyPrefix}, storage.KeysOnly) for iterator.Next(it) { - key := iterator.Value(it).([]byte) - // V2 format - if len(key) == containerIDSize { - count++ - } + count++ } return count } +// ContainersOf iterates over all container IDs owned by the specified owner. +// If owner is nil, it iterates over all containers. +func ContainersOf(owner []byte) iterator.Iterator { + ctx := storage.GetReadOnlyContext() + key := []byte{ownerKeyPrefix} + if len(owner) != 0 { + key = append(key, owner...) + } + return storage.Find(ctx, key, storage.ValuesOnly) +} + // List method returns a list of all container IDs owned by the specified owner. func List(owner []byte) [][]byte { ctx := storage.GetReadOnlyContext() @@ -412,7 +441,7 @@ func List(owner []byte) [][]byte { var list [][]byte - it := storage.Find(ctx, owner, storage.ValuesOnly) + it := storage.Find(ctx, append([]byte{ownerKeyPrefix}, owner...), storage.ValuesOnly) for iterator.Next(it) { id := iterator.Value(it).([]byte) list = append(list, id) @@ -551,7 +580,7 @@ func GetContainerSize(id []byte) containerSizes { } // ListContainerSizes method returns the IDs of container size estimations -// that has been registered for the specified epoch. +// that have been registered for the specified epoch. func ListContainerSizes(epoch int) [][]byte { ctx := storage.GetReadOnlyContext() @@ -582,6 +611,19 @@ func ListContainerSizes(epoch int) [][]byte { return result } +// IterateContainerSizes method returns iterator over container size estimations +// that have been registered for the specified epoch. +func IterateContainerSizes(epoch int) iterator.Iterator { + ctx := storage.GetReadOnlyContext() + + var buf interface{} = epoch + + key := []byte(estimateKeyPrefix) + key = append(key, buf.([]byte)...) + + return storage.Find(ctx, key, storage.DeserializeValues) +} + // NewEpoch method removes all container size estimations from epoch older than // epochNum + 3. It can be invoked only by NewEpoch method of the Netmap contract. func NewEpoch(epochNum int) { @@ -687,29 +729,30 @@ func Version() int { } func addContainer(ctx storage.Context, id, owner []byte, container Container) { - containerListKey := append(owner, id...) + containerListKey := append([]byte{ownerKeyPrefix}, owner...) + containerListKey = append(containerListKey, id...) storage.Put(ctx, containerListKey, id) - common.SetSerialized(ctx, id, container) + idKey := append([]byte{containerKeyPrefix}, id...) + common.SetSerialized(ctx, idKey, container) } func removeContainer(ctx storage.Context, id []byte, owner []byte) { - containerListKey := append(owner, id...) + containerListKey := append([]byte{ownerKeyPrefix}, owner...) + containerListKey = append(containerListKey, id...) storage.Delete(ctx, containerListKey) - storage.Delete(ctx, id) + storage.Delete(ctx, append([]byte{containerKeyPrefix}, id...)) } func getAllContainers(ctx storage.Context) [][]byte { var list [][]byte - it := storage.Find(ctx, []byte{}, storage.KeysOnly) + it := storage.Find(ctx, []byte{containerKeyPrefix}, storage.KeysOnly|storage.RemovePrefix) for iterator.Next(it) { key := iterator.Value(it).([]byte) // it MUST BE `storage.KeysOnly` // V2 format - if len(key) == containerIDSize { - list = append(list, key) - } + list = append(list, key) } return list @@ -726,7 +769,7 @@ func getEACL(ctx storage.Context, cid []byte) ExtendedACL { } func getContainer(ctx storage.Context, cid []byte) Container { - data := storage.Get(ctx, cid) + data := storage.Get(ctx, append([]byte{containerKeyPrefix}, cid...)) if data != nil { return std.Deserialize(data.([]byte)).(Container) } diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 00000000..ee8b0cc8 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +neofs-contract (0.0.0) stable; urgency=medium + + * Initial release + + -- NeoSPCC Wed, 24 Aug 2022 18:29:49 +0300 diff --git a/debian/control b/debian/control new file mode 100644 index 00000000..4efdc9e5 --- /dev/null +++ b/debian/control @@ -0,0 +1,34 @@ +Source: neofs-contract +Section: misc +Priority: optional +Maintainer: NeoSPCC +Build-Depends: debhelper-compat (= 13), git, devscripts, neo-go +Standards-Version: 4.5.1 +Homepage: https://fs.neo.org/ +Vcs-Git: https://github.com/nspcc-dev/neofs-contract.git +Vcs-Browser: https://github.com/nspcc-dev/neofs-contract + +Package: neofs-contract +Architecture: all +Depends: ${misc:Depends} +Description: NeoFS-Contract contains all NeoFS related contracts. + Contracts are written for neo-go compiler. + These contracts are deployed both in the mainchain and the sidechain. + . + Mainchain contracts: + . + - neofs + - processing + . + Sidechain contracts: + . + - alphabet + - audit + - balance + - container + - neofsid + - netmap + - nns + - proxy + - reputation + - subnet diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 00000000..a0fc66d4 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,22 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: neofs-contract +Upstream-Contact: tech@nspcc.ru +Source: https://github.com/nspcc-dev/neofs-contract + +Files: * +Copyright: 2018-2022 NeoSPCC (@nspcc-dev) + +License: GPL-3 + This program is free software: you can redistribute it and/or modify it + under the terms of the GNU General Public License as published + by the Free Software Foundation; either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program or at /usr/share/common-licenses/GPL-3. + If not, see . diff --git a/debian/neofs-contract.docs b/debian/neofs-contract.docs new file mode 100644 index 00000000..ab1a32b8 --- /dev/null +++ b/debian/neofs-contract.docs @@ -0,0 +1 @@ +README* diff --git a/debian/postinst.ex b/debian/postinst.ex new file mode 100644 index 00000000..47eefa4e --- /dev/null +++ b/debian/postinst.ex @@ -0,0 +1,39 @@ +#!/bin/sh +# postinst script for neofs-contract +# +# see: dh_installdeb(1) + +set -e + +# summary of how this script can be called: +# * `configure' +# * `abort-upgrade' +# * `abort-remove' `in-favour' +# +# * `abort-remove' +# * `abort-deconfigure' `in-favour' +# `removing' +# +# for details, see https://www.debian.org/doc/debian-policy/ or +# the debian-policy package + + +case "$1" in + configure) + ;; + + abort-upgrade|abort-remove|abort-deconfigure) + ;; + + *) + echo "postinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +exit 0 diff --git a/debian/postrm.ex b/debian/postrm.ex new file mode 100644 index 00000000..e2bc9101 --- /dev/null +++ b/debian/postrm.ex @@ -0,0 +1,37 @@ +#!/bin/sh +# postrm script for neofs-contract +# +# see: dh_installdeb(1) + +set -e + +# summary of how this script can be called: +# * `remove' +# * `purge' +# * `upgrade' +# * `failed-upgrade' +# * `abort-install' +# * `abort-install' +# * `abort-upgrade' +# * `disappear' +# +# for details, see https://www.debian.org/doc/debian-policy/ or +# the debian-policy package + + +case "$1" in + purge|remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) + ;; + + *) + echo "postrm called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +exit 0 diff --git a/debian/preinst.ex b/debian/preinst.ex new file mode 100644 index 00000000..af5a071e --- /dev/null +++ b/debian/preinst.ex @@ -0,0 +1,35 @@ +#!/bin/sh +# preinst script for neofs-contract +# +# see: dh_installdeb(1) + +set -e + +# summary of how this script can be called: +# * `install' +# * `install' +# * `upgrade' +# * `abort-upgrade' +# for details, see https://www.debian.org/doc/debian-policy/ or +# the debian-policy package + + +case "$1" in + install|upgrade) + ;; + + abort-upgrade) + ;; + + *) + echo "preinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +exit 0 diff --git a/debian/prerm.ex b/debian/prerm.ex new file mode 100644 index 00000000..60e41bd4 --- /dev/null +++ b/debian/prerm.ex @@ -0,0 +1,38 @@ +#!/bin/sh +# prerm script for neofs-contract +# +# see: dh_installdeb(1) + +set -e + +# summary of how this script can be called: +# * `remove' +# * `upgrade' +# * `failed-upgrade' +# * `remove' `in-favour' +# * `deconfigure' `in-favour' +# `removing' +# +# for details, see https://www.debian.org/doc/debian-policy/ or +# the debian-policy package + + +case "$1" in + remove|upgrade|deconfigure) + ;; + + failed-upgrade) + ;; + + *) + echo "prerm called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +exit 0 diff --git a/debian/rules b/debian/rules new file mode 100755 index 00000000..0e3ddd10 --- /dev/null +++ b/debian/rules @@ -0,0 +1,20 @@ +#!/usr/bin/make -f + +SERVICE = neofs-contract +export NEOGO ?= $(shell command -v neo-go) + +%: + dh $@ + +override_dh_auto_build: + + make all + +override_dh_auto_install: + install -D -m 0750 -d debian/$(SERVICE)/var/lib/neofs/contract + find . -maxdepth 2 \( -name '*.nef' -o -name 'config.json' \) -exec cp --parents \{\} debian/$(SERVICE)/var/lib/neofs/contract \; + +override_dh_installchangelogs: + dh_installchangelogs -k CHANGELOG.md + + diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 00000000..163aaf8d --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/netmap/netmap_contract.go b/netmap/netmap_contract.go index 61120325..932c6dd0 100644 --- a/netmap/netmap_contract.go +++ b/netmap/netmap_contract.go @@ -43,6 +43,21 @@ type Node struct { State NodeState } +// Temporary migration-related types. +type oldNode struct { + BLOB []byte +} + +type oldCandidate struct { + f1 oldNode + f2 NodeState +} + +type kv struct { + k []byte + v []byte +} + const ( notaryDisabledKey = "notary" innerRingKey = "innerring" @@ -94,6 +109,42 @@ func _deploy(data interface{}, isUpdate bool) { if isUpdate { common.CheckVersion(args.version) + + if args.version >= 16*1_000 { // 0.16.0+ already have appropriate format + return + } + + count := getSnapshotCount(ctx) + prefix := []byte(snapshotKeyPrefix) + for i := 0; i < count; i++ { + key := append(prefix, byte(i)) + data := storage.Get(ctx, key) + if data != nil { + nodes := std.Deserialize(data.([]byte)).([]oldNode) + var newnodes []Node + for j := range nodes { + // Old structure contains only the first field, + // second is implicitly assumed to be Online. + newnodes = append(newnodes, Node{ + BLOB: nodes[j].BLOB, + State: NodeStateOnline, + }) + } + common.SetSerialized(ctx, key, newnodes) + } + } + + it := storage.Find(ctx, candidatePrefix, storage.None) + for iterator.Next(it) { + cand := iterator.Value(it).(kv) + oldcan := std.Deserialize(cand.v).(oldCandidate) + newcan := Node{ + BLOB: oldcan.f1.BLOB, + State: oldcan.f2, + } + common.SetSerialized(ctx, cand.k, newcan) + } + return } diff --git a/tests/container_test.go b/tests/container_test.go index 9a265e17..ee50be14 100644 --- a/tests/container_test.go +++ b/tests/container_test.go @@ -3,10 +3,12 @@ package tests import ( "bytes" "crypto/sha256" + "math/big" "path" "testing" "github.com/mr-tron/base58" + "github.com/nspcc-dev/neo-go/pkg/core/interop/storage" "github.com/nspcc-dev/neo-go/pkg/encoding/address" "github.com/nspcc-dev/neo-go/pkg/neotest" "github.com/nspcc-dev/neo-go/pkg/util" @@ -101,15 +103,62 @@ func TestContainerCount(t *testing.T) { cnt3 := dummyContainer(acc1) balanceMint(t, cBal, acc1, containerFee*1, []byte{}) c.Invoke(t, stackitem.Null{}, "put", cnt3.value, cnt3.sig, cnt3.pub, cnt3.token) + checkContainerList(t, c, [][]byte{cnt1.id[:], cnt2.id[:], cnt3.id[:]}) c.Invoke(t, stackitem.Null{}, "delete", cnt1.id[:], cnt1.sig, cnt1.token) checkCount(t, 2) + checkContainerList(t, c, [][]byte{cnt2.id[:], cnt3.id[:]}) c.Invoke(t, stackitem.Null{}, "delete", cnt2.id[:], cnt2.sig, cnt2.token) checkCount(t, 1) + checkContainerList(t, c, [][]byte{cnt3.id[:]}) c.Invoke(t, stackitem.Null{}, "delete", cnt3.id[:], cnt3.sig, cnt3.token) checkCount(t, 0) + checkContainerList(t, c, [][]byte{}) +} + +func checkContainerList(t *testing.T, c *neotest.ContractInvoker, expected [][]byte) { + t.Run("check with `list`", func(t *testing.T) { + s, err := c.TestInvoke(t, "list", nil) + require.NoError(t, err) + require.Equal(t, 1, s.Len()) + + if len(expected) == 0 { + _, ok := s.Top().Item().(stackitem.Null) + require.True(t, ok) + return + } + + arr, ok := s.Top().Value().([]stackitem.Item) + require.True(t, ok) + require.Equal(t, len(expected), len(arr)) + + actual := make([][]byte, 0, len(expected)) + for i := range arr { + id, ok := arr[i].Value().([]byte) + require.True(t, ok) + actual = append(actual, id) + } + require.ElementsMatch(t, expected, actual) + }) + t.Run("check with `containersOf`", func(t *testing.T) { + s, err := c.TestInvoke(t, "containersOf", nil) + require.NoError(t, err) + require.Equal(t, 1, s.Len()) + + iter, ok := s.Top().Value().(*storage.Iterator) + require.True(t, ok) + + actual := make([][]byte, 0, len(expected)) + for iter.Next() { + id, ok := iter.Value().Value().([]byte) + require.True(t, ok) + actual = append(actual, id) + } + require.ElementsMatch(t, expected, actual) + }) + } func TestContainerPut(t *testing.T) { @@ -366,6 +415,16 @@ type estimation struct { } func checkEstimations(t *testing.T, c *neotest.ContractInvoker, epoch int64, cnt testContainer, estimations ...estimation) { + // Check that listed estimations match expected + listEstimations := getListEstimations(t, c, epoch, cnt) + requireEstimationsMatch(t, estimations, listEstimations) + + // Check that iterated estimations match expected + iterEstimations := getIterEstimations(t, c, epoch) + requireEstimationsMatch(t, estimations, iterEstimations) +} + +func getListEstimations(t *testing.T, c *neotest.ContractInvoker, epoch int64, cnt testContainer) []estimation { s, err := c.TestInvoke(t, "listContainerSizes", epoch) require.NoError(t, err) @@ -375,9 +434,8 @@ func checkEstimations(t *testing.T, c *neotest.ContractInvoker, epoch int64, cnt item := s.Top().Item() switch it := item.(type) { case stackitem.Null: - require.Equal(t, 0, len(estimations)) require.Equal(t, stackitem.Null{}, it) - return + return make([]estimation, 0) case *stackitem.Array: id, err = it.Value().([]stackitem.Item)[0].TryBytes() require.NoError(t, err) @@ -388,25 +446,52 @@ func checkEstimations(t *testing.T, c *neotest.ContractInvoker, epoch int64, cnt s, err = c.TestInvoke(t, "getContainerSize", id) require.NoError(t, err) + // Here and below we assume that all estimations in the contract are related to our container sizes := s.Top().Array() require.Equal(t, cnt.id[:], sizes[0].Value()) - actual := sizes[1].Value().([]stackitem.Item) - require.Equal(t, len(estimations), len(actual)) - for i := range actual { - // type estimation struct { - // from interop.PublicKey - // size int - // } - est := actual[i].Value().([]stackitem.Item) - pub := est[0].Value().([]byte) + return convertStackToEstimations(sizes[1].Value().([]stackitem.Item)) +} + +func getIterEstimations(t *testing.T, c *neotest.ContractInvoker, epoch int64) []estimation { + iterStack, err := c.TestInvoke(t, "iterateContainerSizes", epoch) + require.NoError(t, err) + iter := iterStack.Pop().Value().(*storage.Iterator) + + // Iterator contains pairs: key + estimation (as stack item), we extract estimations only + pairs := iteratorToArray(iter) + estimationItems := make([]stackitem.Item, len(pairs)) + for i, pair := range pairs { + pairItems := pair.Value().([]stackitem.Item) + estimationItems[i] = pairItems[1] + } + + return convertStackToEstimations(estimationItems) +} + +func convertStackToEstimations(stackItems []stackitem.Item) []estimation { + estimations := make([]estimation, 0, len(stackItems)) + for _, item := range stackItems { + value := item.Value().([]stackitem.Item) + from := value[0].Value().([]byte) + size := value[1].Value().(*big.Int) + + estimation := estimation{from: from, size: size.Int64()} + estimations = append(estimations, estimation) + } + return estimations +} + +func requireEstimationsMatch(t *testing.T, expected []estimation, actual []estimation) { + require.Equal(t, len(expected), len(actual)) + for _, e := range expected { found := false - for i := range estimations { - if found = bytes.Equal(estimations[i].from, pub); found { - require.Equal(t, stackitem.Make(estimations[i].size), est[1]) + for _, a := range actual { + if found = bytes.Equal(e.from, a.from); found { + require.Equal(t, e.size, a.size) break } } - require.True(t, found, "expected estimation from %x to be present", pub) + require.True(t, found, "expected estimation from %x to be present", e.from) } } diff --git a/tests/util.go b/tests/util.go index 1e2958de..48f5f5d8 100644 --- a/tests/util.go +++ b/tests/util.go @@ -3,10 +3,20 @@ package tests import ( "testing" + "github.com/nspcc-dev/neo-go/pkg/core/interop/storage" "github.com/nspcc-dev/neo-go/pkg/neotest" "github.com/nspcc-dev/neo-go/pkg/neotest/chain" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" ) +func iteratorToArray(iter *storage.Iterator) []stackitem.Item { + stackItems := make([]stackitem.Item, 0) + for iter.Next() { + stackItems = append(stackItems, iter.Value()) + } + return stackItems +} + func newExecutor(t *testing.T) *neotest.Executor { bc, acc := chain.NewSingle(t) return neotest.NewExecutor(t, bc, acc, acc)