neotest: add contract testing framework
Signed-off-by: Evgeniy Stratonikov <evgeniy@nspcc.ru>
This commit is contained in:
parent
1d16016027
commit
233fca0c1e
5 changed files with 417 additions and 3 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -28,9 +28,8 @@ bin/
|
||||||
*~
|
*~
|
||||||
TAGS
|
TAGS
|
||||||
|
|
||||||
# leveldb
|
# storage
|
||||||
chains/
|
/chains
|
||||||
chain/
|
|
||||||
|
|
||||||
# patch
|
# patch
|
||||||
*.orig
|
*.orig
|
||||||
|
|
8
pkg/neotest/account.go
Normal file
8
pkg/neotest/account.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package neotest
|
||||||
|
|
||||||
|
var _nonce uint32
|
||||||
|
|
||||||
|
func nonce() uint32 {
|
||||||
|
_nonce++
|
||||||
|
return _nonce
|
||||||
|
}
|
272
pkg/neotest/basic.go
Normal file
272
pkg/neotest/basic.go
Normal file
|
@ -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
|
||||||
|
}
|
50
pkg/neotest/chain/chain.go
Normal file
50
pkg/neotest/chain/chain.go
Normal file
|
@ -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
|
||||||
|
}
|
85
pkg/neotest/compile.go
Normal file
85
pkg/neotest/compile.go
Normal file
|
@ -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
|
||||||
|
}
|
Loading…
Reference in a new issue