From 233fca0c1e780da71b989f6a5e7c2a655cb25131 Mon Sep 17 00:00:00 2001 From: Evgeniy Stratonikov Date: Sat, 23 Oct 2021 14:28:03 +0300 Subject: [PATCH] neotest: add contract testing framework Signed-off-by: Evgeniy Stratonikov --- .gitignore | 5 +- pkg/neotest/account.go | 8 ++ pkg/neotest/basic.go | 272 +++++++++++++++++++++++++++++++++++++ pkg/neotest/chain/chain.go | 50 +++++++ pkg/neotest/compile.go | 85 ++++++++++++ 5 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 pkg/neotest/account.go create mode 100644 pkg/neotest/basic.go create mode 100644 pkg/neotest/chain/chain.go create mode 100644 pkg/neotest/compile.go diff --git a/.gitignore b/.gitignore index 89e285f84..cdaca8c9e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,8 @@ bin/ *~ TAGS -# leveldb -chains/ -chain/ +# storage +/chains # patch *.orig diff --git a/pkg/neotest/account.go b/pkg/neotest/account.go new file mode 100644 index 000000000..00c95a7a0 --- /dev/null +++ b/pkg/neotest/account.go @@ -0,0 +1,8 @@ +package neotest + +var _nonce uint32 + +func nonce() uint32 { + _nonce++ + return _nonce +} diff --git a/pkg/neotest/basic.go b/pkg/neotest/basic.go new file mode 100644 index 000000000..b374e0d53 --- /dev/null +++ b/pkg/neotest/basic.go @@ -0,0 +1,272 @@ +package neotest + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/nspcc-dev/neo-go/pkg/config/netmode" + "github.com/nspcc-dev/neo-go/pkg/core/block" + "github.com/nspcc-dev/neo-go/pkg/core/blockchainer" + "github.com/nspcc-dev/neo-go/pkg/core/fee" + "github.com/nspcc-dev/neo-go/pkg/core/native" + "github.com/nspcc-dev/neo-go/pkg/core/native/nativenames" + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/core/transaction" + "github.com/nspcc-dev/neo-go/pkg/io" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm" + "github.com/nspcc-dev/neo-go/pkg/vm/emit" + "github.com/nspcc-dev/neo-go/pkg/vm/opcode" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/nspcc-dev/neo-go/pkg/wallet" + "github.com/stretchr/testify/require" +) + +// Executor is a wrapper over chain state. +type Executor struct { + Chain blockchainer.Blockchainer + Committee *wallet.Account + CommitteeHash util.Uint160 + Contracts map[string]*Contract +} + +// NewExecutor creates new executor instance from provided blockchain and committee. +func NewExecutor(t *testing.T, bc blockchainer.Blockchainer, committee *wallet.Account) *Executor { + require.Equal(t, 1, len(bc.GetConfig().StandbyCommittee)) + + return &Executor{ + Chain: bc, + Committee: committee, + CommitteeHash: committee.Contract.ScriptHash(), + Contracts: make(map[string]*Contract), + } +} + +// TopBlock returns block with the highest index. +func (e *Executor) TopBlock(t *testing.T) *block.Block { + b, err := e.Chain.GetBlock(e.Chain.GetHeaderHash(int(e.Chain.BlockHeight()))) + require.NoError(t, err) + return b +} + +// NativeHash returns native contract hash by name. +func (e *Executor) NativeHash(t *testing.T, name string) util.Uint160 { + h, err := e.Chain.GetNativeContractScriptHash(name) + require.NoError(t, err) + return h +} + +// NewUnsignedTx creates new unsigned transaction which invokes method of contract with hash. +func (e *Executor) NewUnsignedTx(t *testing.T, hash util.Uint160, method string, args ...interface{}) *transaction.Transaction { + w := io.NewBufBinWriter() + emit.AppCall(w.BinWriter, hash, method, callflag.All, args...) + require.NoError(t, w.Err) + + script := w.Bytes() + tx := transaction.New(script, 0) + tx.Nonce = nonce() + tx.ValidUntilBlock = e.Chain.BlockHeight() + 1 + return tx +} + +// NewTx creates new transaction which invokes contract method. +// Transaction is signed with signer. +func (e *Executor) NewTx(t *testing.T, signer interface{}, + hash util.Uint160, method string, args ...interface{}) *transaction.Transaction { + tx := e.NewUnsignedTx(t, hash, method, args...) + return e.SignTx(t, tx, -1, signer) +} + +// SignTx signs a transaction using provided signers. +// signers can be either *wallet.Account or []*wallet.Account. +func (e *Executor) SignTx(t *testing.T, tx *transaction.Transaction, sysFee int64, signers interface{}) *transaction.Transaction { + switch s := signers.(type) { + case *wallet.Account: + tx.Signers = append(tx.Signers, transaction.Signer{ + Account: s.Contract.ScriptHash(), + Scopes: transaction.Global, + }) + addNetworkFee(e.Chain, tx, s) + addSystemFee(e.Chain, tx, sysFee) + require.NoError(t, s.SignTx(e.Chain.GetConfig().Magic, tx)) + case []*wallet.Account: + for _, acc := range s { + tx.Signers = append(tx.Signers, transaction.Signer{ + Account: acc.Contract.ScriptHash(), + Scopes: transaction.Global, + }) + } + for _, acc := range s { + addNetworkFee(e.Chain, tx, acc) + } + addSystemFee(e.Chain, tx, sysFee) + for _, acc := range s { + require.NoError(t, acc.SignTx(e.Chain.GetConfig().Magic, tx)) + } + default: + panic("invalid signer") + } + + return tx +} + +// NewAccount returns new account holding 100.0 GAS. This method advances the chain +// by one block with a transfer transaction. +func (e *Executor) NewAccount(t *testing.T) *wallet.Account { + acc, err := wallet.NewAccount() + require.NoError(t, err) + + tx := e.NewTx(t, e.Committee, + e.NativeHash(t, nativenames.Gas), "transfer", + e.Committee.Contract.ScriptHash(), acc.Contract.ScriptHash(), int64(100_0000_0000), nil) + e.AddNewBlock(t, tx) + e.CheckHalt(t, tx.Hash()) + return acc +} + +// DeployContract compiles and deploys contract to bc. +// data is an optional argument to `_deploy`. +// Returns hash of the deploy transaction. +func (e *Executor) DeployContract(t *testing.T, c *Contract, data interface{}) util.Uint256 { + tx := e.NewDeployTx(t, e.Chain, c, data) + e.AddNewBlock(t, tx) + e.CheckHalt(t, tx.Hash()) + return tx.Hash() +} + +// CheckHalt checks that transaction persisted with HALT state. +func (e *Executor) CheckHalt(t *testing.T, h util.Uint256, stack ...stackitem.Item) *state.AppExecResult { + aer, err := e.Chain.GetAppExecResults(h, trigger.Application) + require.NoError(t, err) + require.Equal(t, vm.HaltState, aer[0].VMState, aer[0].FaultException) + if len(stack) != 0 { + require.Equal(t, stack, aer[0].Stack) + } + return &aer[0] +} + +// CheckFault checks that transaction persisted with FAULT state. +// Raised exception is also checked to contain s as a substring. +func (e *Executor) CheckFault(t *testing.T, h util.Uint256, s string) { + aer, err := e.Chain.GetAppExecResults(h, trigger.Application) + require.NoError(t, err) + require.Equal(t, vm.FaultState, aer[0].VMState) + require.True(t, strings.Contains(aer[0].FaultException, s), + "expected: %s, got: %s", s, aer[0].FaultException) +} + +// NewDeployTx returns new deployment tx for contract signed by committee. +func (e *Executor) NewDeployTx(t *testing.T, bc blockchainer.Blockchainer, c *Contract, data interface{}) *transaction.Transaction { + rawManifest, err := json.Marshal(c.Manifest) + require.NoError(t, err) + + neb, err := c.NEF.Bytes() + require.NoError(t, err) + + buf := io.NewBufBinWriter() + emit.AppCall(buf.BinWriter, bc.ManagementContractHash(), "deploy", callflag.All, neb, rawManifest, data) + require.NoError(t, buf.Err) + + tx := transaction.New(buf.Bytes(), 100*native.GASFactor) + tx.Nonce = nonce() + tx.ValidUntilBlock = bc.BlockHeight() + 1 + tx.Signers = []transaction.Signer{{ + Account: e.Committee.Contract.ScriptHash(), + Scopes: transaction.Global, + }} + addNetworkFee(bc, tx, e.Committee) + require.NoError(t, e.Committee.SignTx(netmode.UnitTestNet, tx)) + return tx +} + +func addSystemFee(bc blockchainer.Blockchainer, tx *transaction.Transaction, sysFee int64) { + if sysFee >= 0 { + tx.SystemFee = sysFee + return + } + v, _ := TestInvoke(bc, tx) // ignore error to support failing transactions + tx.SystemFee = v.GasConsumed() +} + +func addNetworkFee(bc blockchainer.Blockchainer, tx *transaction.Transaction, sender *wallet.Account) { + baseFee := bc.GetPolicer().GetBaseExecFee() + size := io.GetVarSize(tx) + netFee, sizeDelta := fee.Calculate(baseFee, sender.Contract.Script) + tx.NetworkFee += netFee + size += sizeDelta + tx.NetworkFee += int64(size) * bc.FeePerByte() +} + +// NewUnsignedBlock creates new unsigned block from txs. +func (e *Executor) NewUnsignedBlock(t *testing.T, txs ...*transaction.Transaction) *block.Block { + lastBlock := e.TopBlock(t) + b := &block.Block{ + Header: block.Header{ + NextConsensus: e.Committee.Contract.ScriptHash(), + Script: transaction.Witness{ + VerificationScript: e.Committee.Contract.Script, + }, + Timestamp: lastBlock.Timestamp + 1, + }, + Transactions: txs, + } + if e.Chain.GetConfig().StateRootInHeader { + b.StateRootEnabled = true + b.PrevStateRoot = e.Chain.GetStateModule().CurrentLocalStateRoot() + } + b.PrevHash = lastBlock.Hash() + b.Index = e.Chain.BlockHeight() + 1 + b.RebuildMerkleRoot() + return b +} + +// AddNewBlock creates a new block from provided transactions and adds it on bc. +func (e *Executor) AddNewBlock(t *testing.T, txs ...*transaction.Transaction) *block.Block { + b := e.NewUnsignedBlock(t, txs...) + e.SignBlock(b) + require.NoError(t, e.Chain.AddBlock(b)) + return b +} + +// SignBlock add validators signature to b. +func (e *Executor) SignBlock(b *block.Block) *block.Block { + sign := e.Committee.PrivateKey().SignHashable(uint32(e.Chain.GetConfig().Magic), b) + b.Script.InvocationScript = append([]byte{byte(opcode.PUSHDATA1), 64}, sign...) + return b +} + +// AddBlockCheckHalt is a convenient wrapper over AddNewBlock and CheckHalt. +func (e *Executor) AddBlockCheckHalt(t *testing.T, bc blockchainer.Blockchainer, txs ...*transaction.Transaction) *block.Block { + b := e.AddNewBlock(t, txs...) + for _, tx := range txs { + e.CheckHalt(t, tx.Hash()) + } + return b +} + +// TestInvoke creates a test VM with dummy block and executes transaction in it. +func TestInvoke(bc blockchainer.Blockchainer, tx *transaction.Transaction) (*vm.VM, error) { + lastBlock, err := bc.GetBlock(bc.GetHeaderHash(int(bc.BlockHeight()))) + if err != nil { + return nil, err + } + b := &block.Block{ + Header: block.Header{ + Index: bc.BlockHeight() + 1, + Timestamp: lastBlock.Timestamp + 1, + }, + } + + // `GetTestVM` as well as `Run` can use transaction hash which will set cached value. + // This is unwanted behaviour so we explicitly copy transaction to perform execution. + ttx := *tx + v, f := bc.GetTestVM(trigger.Application, &ttx, b) + defer f() + + v.LoadWithFlags(tx.Script, callflag.All) + err = v.Run() + return v, err +} diff --git a/pkg/neotest/chain/chain.go b/pkg/neotest/chain/chain.go new file mode 100644 index 000000000..24d79393f --- /dev/null +++ b/pkg/neotest/chain/chain.go @@ -0,0 +1,50 @@ +package chain + +import ( + "encoding/hex" + "testing" + + "github.com/nspcc-dev/neo-go/pkg/config" + "github.com/nspcc-dev/neo-go/pkg/config/netmode" + "github.com/nspcc-dev/neo-go/pkg/core" + "github.com/nspcc-dev/neo-go/pkg/core/storage" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neo-go/pkg/wallet" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +const validatorWIF = "KxyjQ8eUa4FHt3Gvioyt1Wz29cTUrE4eTqX3yFSk1YFCsPL8uNsY" + +// committeeAcc is an account used to sign tx as a committee. +var committeeAcc *wallet.Account + +func init() { + committeeAcc, _ = wallet.NewAccountFromWIF(validatorWIF) + pubs := keys.PublicKeys{committeeAcc.PrivateKey().PublicKey()} + err := committeeAcc.ConvertMultisig(1, pubs) + if err != nil { + panic(err) + } +} + +// NewSingle creates new blockchain instance with a single validator and +// setups cleanup functions. +func NewSingle(t *testing.T) (*core.Blockchain, *wallet.Account) { + protoCfg := config.ProtocolConfiguration{ + Magic: netmode.UnitTestNet, + SecondsPerBlock: 1, + StandbyCommittee: []string{hex.EncodeToString(committeeAcc.PrivateKey().PublicKey().Bytes())}, + ValidatorsCount: 1, + VerifyBlocks: true, + VerifyTransactions: true, + } + + st := storage.NewMemoryStore() + log := zaptest.NewLogger(t) + bc, err := core.NewBlockchain(st, protoCfg, log) + require.NoError(t, err) + go bc.Run() + t.Cleanup(bc.Close) + return bc, committeeAcc +} diff --git a/pkg/neotest/compile.go b/pkg/neotest/compile.go new file mode 100644 index 000000000..cd9831c8d --- /dev/null +++ b/pkg/neotest/compile.go @@ -0,0 +1,85 @@ +package neotest + +import ( + "io" + "testing" + + "github.com/nspcc-dev/neo-go/cli/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/compiler" + "github.com/nspcc-dev/neo-go/pkg/config" + "github.com/nspcc-dev/neo-go/pkg/core/state" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/nef" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/stretchr/testify/require" +) + +// Contract contains contract info for deployment. +type Contract struct { + Hash util.Uint160 + NEF *nef.File + Manifest *manifest.Manifest +} + +// contracts caches compiled contracts from FS across multiple tests. +var contracts = make(map[string]*Contract) + +// CompileSource compiles contract from reader and returns it's NEF, manifest and hash. +func CompileSource(t *testing.T, sender util.Uint160, src io.Reader, opts *compiler.Options) *Contract { + // nef.NewFile() cares about version a lot. + config.Version = "neotest" + + avm, di, err := compiler.CompileWithDebugInfo(opts.Name, src) + require.NoError(t, err) + + ne, err := nef.NewFile(avm) + require.NoError(t, err) + + m, err := compiler.CreateManifest(di, opts) + require.NoError(t, err) + + return &Contract{ + Hash: state.CreateContractHash(sender, ne.Checksum, m.Name), + NEF: ne, + Manifest: m, + } +} + +// CompileFile compiles contract from file and returns it's NEF, manifest and hash. +func CompileFile(t *testing.T, sender util.Uint160, srcPath string, configPath string) *Contract { + if c, ok := contracts[srcPath]; ok { + return c + } + + // nef.NewFile() cares about version a lot. + config.Version = "neotest" + + avm, di, err := compiler.CompileWithDebugInfo(srcPath, nil) + require.NoError(t, err) + + ne, err := nef.NewFile(avm) + require.NoError(t, err) + + conf, err := smartcontract.ParseContractConfig(configPath) + require.NoError(t, err) + + o := &compiler.Options{} + o.Name = conf.Name + o.ContractEvents = conf.Events + o.ContractSupportedStandards = conf.SupportedStandards + o.Permissions = make([]manifest.Permission, len(conf.Permissions)) + for i := range conf.Permissions { + o.Permissions[i] = manifest.Permission(conf.Permissions[i]) + } + o.SafeMethods = conf.SafeMethods + m, err := compiler.CreateManifest(di, o) + require.NoError(t, err) + + c := &Contract{ + Hash: state.CreateContractHash(sender, ne.Checksum, m.Name), + NEF: ne, + Manifest: m, + } + contracts[srcPath] = c + return c +}