diff --git a/pkg/core/blockchain.go b/pkg/core/blockchain.go index bdd411d55..a1616389b 100644 --- a/pkg/core/blockchain.go +++ b/pkg/core/blockchain.go @@ -2247,7 +2247,7 @@ func (bc *Blockchain) InitVerificationContext(ic *interop.Context, hash util.Uin } ic.Invocations[cs.Hash]++ ic.VM.LoadNEFMethod(&cs.NEF, util.Uint160{}, hash, callflag.ReadOnly, - true, verifyOffset, initOffset) + true, verifyOffset, initOffset, nil) } if len(witness.InvocationScript) != 0 { err := vm.IsScriptCorrect(witness.InvocationScript, nil) diff --git a/pkg/core/interop/context.go b/pkg/core/interop/context.go index abdd8576f..4a734ad42 100644 --- a/pkg/core/interop/context.go +++ b/pkg/core/interop/context.go @@ -381,3 +381,12 @@ func (ic *Context) IsHardforkEnabled(hf config.Hardfork) bool { } return len(ic.Hardforks) == 0 // Enable each hard-fork by default. } + +// AddNotification creates notification event and appends it to the notification list. +func (ic *Context) AddNotification(hash util.Uint160, name string, item *stackitem.Array) { + ic.Notifications = append(ic.Notifications, state.NotificationEvent{ + ScriptHash: hash, + Name: name, + Item: item, + }) +} diff --git a/pkg/core/interop/contract/call.go b/pkg/core/interop/contract/call.go index 5627863ef..41f2f588f 100644 --- a/pkg/core/interop/contract/call.go +++ b/pkg/core/interop/contract/call.go @@ -40,7 +40,7 @@ func LoadToken(ic *interop.Context) func(id int32) error { if err != nil { return fmt.Errorf("token contract %s not found: %w", tok.Hash.StringLE(), err) } - return callInternal(ic, cs, tok.Method, tok.CallFlag, tok.HasReturn, args) + return callInternal(ic, cs, tok.Method, tok.CallFlag, tok.HasReturn, args, false) } } @@ -69,14 +69,11 @@ func Call(ic *interop.Context) error { return fmt.Errorf("method not found: %s/%d", method, len(args)) } hasReturn := md.ReturnType != smartcontract.VoidType - if !hasReturn { - ic.VM.Estack().PushItem(stackitem.Null{}) - } - return callInternal(ic, cs, method, fs, hasReturn, args) + return callInternal(ic, cs, method, fs, hasReturn, args, !hasReturn) } func callInternal(ic *interop.Context, cs *state.Contract, name string, f callflag.CallFlag, - hasReturn bool, args []stackitem.Item) error { + hasReturn bool, args []stackitem.Item, pushNullOnUnloading bool) error { md := cs.Manifest.ABI.GetMethod(name, len(args)) if md.Safe { f &^= (callflag.WriteStates | callflag.AllowNotify) @@ -88,12 +85,12 @@ func callInternal(ic *interop.Context, cs *state.Contract, name string, f callfl } } } - return callExFromNative(ic, ic.VM.GetCurrentScriptHash(), cs, name, args, f, hasReturn) + return callExFromNative(ic, ic.VM.GetCurrentScriptHash(), cs, name, args, f, hasReturn, pushNullOnUnloading, false) } // callExFromNative calls a contract with flags using the provided calling hash. func callExFromNative(ic *interop.Context, caller util.Uint160, cs *state.Contract, - name string, args []stackitem.Item, f callflag.CallFlag, hasReturn bool) error { + name string, args []stackitem.Item, f callflag.CallFlag, hasReturn bool, pushNullOnUnloading bool, callFromNative bool) error { for _, nc := range ic.Natives { if nc.Metadata().Name == nativenames.Policy { var pch = nc.(policyChecker) @@ -119,8 +116,37 @@ func callExFromNative(ic *interop.Context, caller util.Uint160, cs *state.Contra initOff = md.Offset } ic.Invocations[cs.Hash]++ - ic.VM.LoadNEFMethod(&cs.NEF, caller, cs.Hash, ic.VM.Context().GetCallFlags()&f, - hasReturn, methodOff, initOff) + f = ic.VM.Context().GetCallFlags() & f + + wrapped := f&(callflag.All^callflag.ReadOnly) != 0 || // If the method is safe, then it's read-only and doesn't perform storage changes or emit notifications. + ic.VM.Context().HasTryBlock() // If the method is not wrapped into try-catch block, then changes should be discarded anyway if exception occurs. + baseNtfCount := len(ic.Notifications) + baseDAO := ic.DAO + if wrapped { + ic.DAO = ic.DAO.GetPrivate() + } + onUnload := func(commit bool) error { + if wrapped { + if commit { + _, err := ic.DAO.Persist() + if err != nil { + return fmt.Errorf("failed to persist changes %w", err) + } + } else { + ic.Notifications = ic.Notifications[:baseNtfCount] // Rollback all notification changes made by current context. + } + ic.DAO = baseDAO + } + if pushNullOnUnloading && commit { + ic.VM.Context().Estack().PushItem(stackitem.Null{}) // Must use current context stack. + } + if callFromNative && !commit { + return fmt.Errorf("unhandled exception") + } + return nil + } + ic.VM.LoadNEFMethod(&cs.NEF, caller, cs.Hash, f, + hasReturn, methodOff, initOff, onUnload) for e, i := ic.VM.Estack(), len(args)-1; i >= 0; i-- { e.PushItem(args[i]) @@ -134,7 +160,7 @@ var ErrNativeCall = errors.New("failed native call") // CallFromNative performs synchronous call from native contract. func CallFromNative(ic *interop.Context, caller util.Uint160, cs *state.Contract, method string, args []stackitem.Item, hasReturn bool) error { startSize := ic.VM.Istack().Len() - if err := callExFromNative(ic, caller, cs, method, args, callflag.All, hasReturn); err != nil { + if err := callExFromNative(ic, caller, cs, method, args, callflag.All, hasReturn, false, true); err != nil { return err } diff --git a/pkg/core/interop/runtime/engine.go b/pkg/core/interop/runtime/engine.go index b80bb8a98..f34358479 100644 --- a/pkg/core/interop/runtime/engine.go +++ b/pkg/core/interop/runtime/engine.go @@ -6,7 +6,6 @@ import ( "math/big" "github.com/nspcc-dev/neo-go/pkg/core/interop" - "github.com/nspcc-dev/neo-go/pkg/core/state" "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" "go.uber.org/zap" ) @@ -69,12 +68,7 @@ func Notify(ic *interop.Context) error { if len(bytes) > MaxNotificationSize { return fmt.Errorf("notification size shouldn't exceed %d", MaxNotificationSize) } - ne := state.NotificationEvent{ - ScriptHash: ic.VM.GetCurrentScriptHash(), - Name: name, - Item: stackitem.DeepCopy(stackitem.NewArray(args)).(*stackitem.Array), - } - ic.Notifications = append(ic.Notifications, ne) + ic.AddNotification(ic.VM.GetCurrentScriptHash(), name, stackitem.DeepCopy(stackitem.NewArray(args)).(*stackitem.Array)) return nil } diff --git a/pkg/core/interop_system_neotest_test.go b/pkg/core/interop_system_neotest_test.go index 0d83632bd..7fa7ba060 100644 --- a/pkg/core/interop_system_neotest_test.go +++ b/pkg/core/interop_system_neotest_test.go @@ -2,11 +2,14 @@ package core_test import ( "encoding/json" + "fmt" "math" "math/big" + "strings" "testing" "github.com/nspcc-dev/neo-go/internal/contracts" + "github.com/nspcc-dev/neo-go/pkg/compiler" "github.com/nspcc-dev/neo-go/pkg/config" "github.com/nspcc-dev/neo-go/pkg/core/interop" "github.com/nspcc-dev/neo-go/pkg/core/interop/interopnames" @@ -19,6 +22,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/neotest" "github.com/nspcc-dev/neo-go/pkg/neotest/chain" "github.com/nspcc-dev/neo-go/pkg/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" "github.com/nspcc-dev/neo-go/pkg/util" "github.com/nspcc-dev/neo-go/pkg/util/slice" "github.com/nspcc-dev/neo-go/pkg/vm/emit" @@ -311,3 +315,395 @@ func TestSystemContractCreateAccount_Hardfork(t *testing.T) { require.True(t, tx2Standard.SystemFee < tx3Standard.SystemFee) require.True(t, tx2Multisig.SystemFee < tx3Multisig.SystemFee) } + +func TestSnapshotIsolation_Exceptions(t *testing.T) { + bc, acc := chain.NewSingle(t) + e := neotest.NewExecutor(t, bc, acc, acc) + + // Contract A puts value in the storage, emits notifications and panics. + srcA := `package contractA + import ( + "github.com/nspcc-dev/neo-go/pkg/interop/contract" + "github.com/nspcc-dev/neo-go/pkg/interop/runtime" + "github.com/nspcc-dev/neo-go/pkg/interop/storage" + ) + func DoAndPanic(key, value []byte, nNtf int) int { // avoid https://github.com/nspcc-dev/neo-go/issues/2509 + c := storage.GetContext() + storage.Put(c, key, value) + for i := 0; i < nNtf; i++ { + runtime.Notify("NotificationFromA", i) + } + panic("panic from A") + } + func CheckA(key []byte, nNtf int) bool { + c := storage.GetContext() + value := storage.Get(c, key) + // If called from B, then no storage changes made by A should be visible by this moment (they have been discarded after exception handling). + if value != nil { + return false + } + notifications := runtime.GetNotifications(nil) + if len(notifications) != nNtf { + return false + } + // If called from B, then no notifications made by A should be visible by this moment (they have been discarded after exception handling). + for i := 0; i < len(notifications); i++ { + ntf := notifications[i] + name := string(ntf[1].([]byte)) + if name == "NotificationFromA" { + return false + } + } + return true + } + func CheckB() bool { + return contract.Call(runtime.GetCallingScriptHash(), "checkStorageChanges", contract.All).(bool) + }` + ctrA := neotest.CompileSource(t, acc.ScriptHash(), strings.NewReader(srcA), &compiler.Options{ + NoEventsCheck: true, + NoPermissionsCheck: true, + Name: "contractA", + Permissions: []manifest.Permission{{Methods: manifest.WildStrings{Value: nil}}}, + }) + e.DeployContract(t, ctrA, nil) + + var hashAStr string + for i := 0; i < util.Uint160Size; i++ { + hashAStr += fmt.Sprintf("%#x", ctrA.Hash[i]) + if i != util.Uint160Size-1 { + hashAStr += ", " + } + } + // Contract B puts value in the storage, emits notifications and calls A either + // in try-catch block or without it. After that checks that proper notifications + // and storage changes are available from different contexts. + srcB := `package contractB + import ( + "github.com/nspcc-dev/neo-go/pkg/interop" + "github.com/nspcc-dev/neo-go/pkg/interop/contract" + "github.com/nspcc-dev/neo-go/pkg/interop/runtime" + "github.com/nspcc-dev/neo-go/pkg/interop/storage" + "github.com/nspcc-dev/neo-go/pkg/interop/util" + ) + var caughtKey = []byte("caught") + func DoAndCatch(shouldRecover bool, keyA, valueA, keyB, valueB []byte, nNtfA, nNtfB1, nNtfB2 int) { + if shouldRecover { + defer func() { + if r := recover(); r != nil { + keyA := []byte("keyA") // defer can not capture variables from outside + nNtfB1 := 2 + nNtfB2 := 4 + c := storage.GetContext() + storage.Put(c, caughtKey, []byte{}) + for i := 0; i < nNtfB2; i++ { + runtime.Notify("NotificationFromB after panic", i) + } + // Check that storage changes and notifications made by A are reverted. + ok := contract.Call(interop.Hash160{` + hashAStr + `}, "checkA", contract.All, keyA, nNtfB1+nNtfB2).(bool) + if !ok { + util.Abort() // should never ABORT if snapshot isolation is correctly implemented. + } + // Check that storage changes made by B after catch are still available in current context. + ok = CheckStorageChanges() + if !ok { + util.Abort() // should never ABORT if snapshot isolation is correctly implemented. + } + // Check that storage changes made by B after catch are still available from the outside context. + ok = contract.Call(interop.Hash160{` + hashAStr + `}, "checkB", contract.All).(bool) + if !ok { + util.Abort() // should never ABORT if snapshot isolation is correctly implemented. + } + } + }() + } + c := storage.GetContext() + storage.Put(c, keyB, valueB) + for i := 0; i < nNtfB1; i++ { + runtime.Notify("NotificationFromB before panic", i) + } + contract.Call(interop.Hash160{` + hashAStr + `}, "doAndPanic", contract.All, keyA, valueA, nNtfA) + } + func CheckStorageChanges() bool { + c := storage.GetContext() + itm := storage.Get(c, caughtKey) + return itm != nil + }` + ctrB := neotest.CompileSource(t, acc.ScriptHash(), strings.NewReader(srcB), &compiler.Options{ + Name: "contractB", + NoEventsCheck: true, + NoPermissionsCheck: true, + Permissions: []manifest.Permission{{Methods: manifest.WildStrings{Value: nil}}}, + }) + e.DeployContract(t, ctrB, nil) + + keyA := []byte("keyA") // hard-coded in the contract code due to `defer` inability to capture variables from outside. + valueA := []byte("valueA") // hard-coded in the contract code + keyB := []byte("keyB") + valueB := []byte("valueB") + nNtfA := 3 + nNtfBBeforePanic := 2 // hard-coded in the contract code + nNtfBAfterPanic := 4 // hard-coded in the contract code + ctrInvoker := e.NewInvoker(ctrB.Hash, e.Committee) + + // Firstly, do not catch exception and check that all notifications are presented in the notifications list. + h := ctrInvoker.InvokeFail(t, `unhandled exception: "panic from A"`, "doAndCatch", false, keyA, valueA, keyB, valueB, nNtfA, nNtfBBeforePanic, nNtfBAfterPanic) + aer := e.GetTxExecResult(t, h) + require.Equal(t, nNtfBBeforePanic+nNtfA, len(aer.Events)) + + // Then catch exception thrown by A and check that only notifications/storage changes from B are saved. + h = ctrInvoker.Invoke(t, stackitem.Null{}, "doAndCatch", true, keyA, valueA, keyB, valueB, nNtfA, nNtfBBeforePanic, nNtfBAfterPanic) + aer = e.GetTxExecResult(t, h) + require.Equal(t, nNtfBBeforePanic+nNtfBAfterPanic, len(aer.Events)) +} + +// This test is written to test nested calls with try-catch block and proper notifications handling. +func TestSnapshotIsolation_NestedContextException(t *testing.T) { + bc, acc := chain.NewSingle(t) + e := neotest.NewExecutor(t, bc, acc, acc) + + srcA := `package contractA + import ( + "github.com/nspcc-dev/neo-go/pkg/interop/contract" + "github.com/nspcc-dev/neo-go/pkg/interop/runtime" + ) + func CallA() { + runtime.Notify("Calling A") + contract.Call(runtime.GetExecutingScriptHash(), "a", contract.All) + runtime.Notify("Finish") + } + func A() { + defer func() { + if r := recover(); r != nil { + runtime.Notify("Caught") + } + }() + runtime.Notify("A") + contract.Call(runtime.GetExecutingScriptHash(), "b", contract.All) + runtime.Notify("Unreachable A") + } + func B() int { + runtime.Notify("B") + contract.Call(runtime.GetExecutingScriptHash(), "c", contract.All) + runtime.Notify("Unreachable B") + return 5 + } + func C() { + runtime.Notify("C") + panic("exception from C") + }` + ctrA := neotest.CompileSource(t, acc.ScriptHash(), strings.NewReader(srcA), &compiler.Options{ + NoEventsCheck: true, + NoPermissionsCheck: true, + Name: "contractA", + Permissions: []manifest.Permission{{Methods: manifest.WildStrings{Value: nil}}}, + }) + e.DeployContract(t, ctrA, nil) + + ctrInvoker := e.NewInvoker(ctrA.Hash, e.Committee) + h := ctrInvoker.Invoke(t, stackitem.Null{}, "callA") + aer := e.GetTxExecResult(t, h) + require.Equal(t, 4, len(aer.Events)) + require.Equal(t, "Calling A", aer.Events[0].Name) + require.Equal(t, "A", aer.Events[1].Name) + require.Equal(t, "Caught", aer.Events[2].Name) + require.Equal(t, "Finish", aer.Events[3].Name) +} + +// This test is written to avoid https://github.com/neo-project/neo/issues/2746. +func TestSnapshotIsolation_CallToItself(t *testing.T) { + bc, acc := chain.NewSingle(t) + e := neotest.NewExecutor(t, bc, acc, acc) + + // Contract A calls method of self and throws if storage changes made by Do are unavailable after call to it. + srcA := `package contractA + import ( + "github.com/nspcc-dev/neo-go/pkg/interop/contract" + "github.com/nspcc-dev/neo-go/pkg/interop/runtime" + "github.com/nspcc-dev/neo-go/pkg/interop/storage" + ) + var key = []byte("key") + func Test() { + contract.Call(runtime.GetExecutingScriptHash(), "callMyselfAndCheck", contract.All) + } + func CallMyselfAndCheck() { + contract.Call(runtime.GetExecutingScriptHash(), "do", contract.All) + c := storage.GetContext() + val := storage.Get(c, key) + if val == nil { + panic("changes from previous context were not persisted") + } + } + func Do() { + c := storage.GetContext() + storage.Put(c, key, []byte("value")) + } + func Check() { + c := storage.GetContext() + val := storage.Get(c, key) + if val == nil { + panic("value is nil") + } + } +` + ctrA := neotest.CompileSource(t, acc.ScriptHash(), strings.NewReader(srcA), &compiler.Options{ + NoEventsCheck: true, + NoPermissionsCheck: true, + Name: "contractA", + Permissions: []manifest.Permission{{Methods: manifest.WildStrings{Value: nil}}}, + }) + e.DeployContract(t, ctrA, nil) + + ctrInvoker := e.NewInvoker(ctrA.Hash, e.Committee) + ctrInvoker.Invoke(t, stackitem.Null{}, "test") + + // A separate call is needed to check whether all VM contexts were properly + // unwrapped and persisted during the previous call. + ctrInvoker.Invoke(t, stackitem.Null{}, "check") +} + +// This test is written to check https://github.com/nspcc-dev/neo-go/issues/2509 +// and https://github.com/neo-project/neo/pull/2745#discussion_r879167180. +func TestRET_after_FINALLY_PanicInsideVoidMethod(t *testing.T) { + bc, acc := chain.NewSingle(t) + e := neotest.NewExecutor(t, bc, acc, acc) + + // Contract A throws catchable exception. It also has a non-void method. + srcA := `package contractA + func Panic() { + panic("panic from A") + } + func ReturnSomeValue() int { + return 5 + }` + ctrA := neotest.CompileSource(t, acc.ScriptHash(), strings.NewReader(srcA), &compiler.Options{ + NoEventsCheck: true, + NoPermissionsCheck: true, + Name: "contractA", + }) + e.DeployContract(t, ctrA, nil) + + var hashAStr string + for i := 0; i < util.Uint160Size; i++ { + hashAStr += fmt.Sprintf("%#x", ctrA.Hash[i]) + if i != util.Uint160Size-1 { + hashAStr += ", " + } + } + // Contract B calls A and catches the exception thrown by A. + srcB := `package contractB + import ( + "github.com/nspcc-dev/neo-go/pkg/interop" + "github.com/nspcc-dev/neo-go/pkg/interop/contract" + ) + func Catch() { + defer func() { + if r := recover(); r != nil { + // Call method with return value to check https://github.com/neo-project/neo/pull/2745#discussion_r879167180. + contract.Call(interop.Hash160{` + hashAStr + `}, "returnSomeValue", contract.All) + } + }() + contract.Call(interop.Hash160{` + hashAStr + `}, "panic", contract.All) + }` + ctrB := neotest.CompileSource(t, acc.ScriptHash(), strings.NewReader(srcB), &compiler.Options{ + Name: "contractB", + NoEventsCheck: true, + NoPermissionsCheck: true, + Permissions: []manifest.Permission{ + { + Methods: manifest.WildStrings{Value: nil}, + }, + }, + }) + e.DeployContract(t, ctrB, nil) + + ctrInvoker := e.NewInvoker(ctrB.Hash, e.Committee) + ctrInvoker.Invoke(t, stackitem.Null{}, "catch") +} + +// This test is written to check https://github.com/neo-project/neo/pull/2745#discussion_r879125733. +func TestRET_after_FINALLY_CallNonVoidAfterVoidMethod(t *testing.T) { + bc, acc := chain.NewSingle(t) + e := neotest.NewExecutor(t, bc, acc, acc) + + // Contract A has two methods. One of them has no return value, and the other has it. + srcA := `package contractA + import "github.com/nspcc-dev/neo-go/pkg/interop/runtime" + func NoRet() { + runtime.Notify("no ret") + } + func HasRet() int { + runtime.Notify("ret") + return 5 + }` + ctrA := neotest.CompileSource(t, acc.ScriptHash(), strings.NewReader(srcA), &compiler.Options{ + NoEventsCheck: true, + NoPermissionsCheck: true, + Name: "contractA", + }) + e.DeployContract(t, ctrA, nil) + + var hashAStr string + for i := 0; i < util.Uint160Size; i++ { + hashAStr += fmt.Sprintf("%#x", ctrA.Hash[i]) + if i != util.Uint160Size-1 { + hashAStr += ", " + } + } + // Contract B calls A in try-catch block. + srcB := `package contractB + import ( + "github.com/nspcc-dev/neo-go/pkg/interop" + "github.com/nspcc-dev/neo-go/pkg/interop/contract" + "github.com/nspcc-dev/neo-go/pkg/interop/util" + ) + func CallAInTryCatch() { + defer func() { + if r := recover(); r != nil { + util.Abort() // should never happen + } + }() + contract.Call(interop.Hash160{` + hashAStr + `}, "noRet", contract.All) + contract.Call(interop.Hash160{` + hashAStr + `}, "hasRet", contract.All) + }` + ctrB := neotest.CompileSource(t, acc.ScriptHash(), strings.NewReader(srcB), &compiler.Options{ + Name: "contractB", + NoEventsCheck: true, + NoPermissionsCheck: true, + Permissions: []manifest.Permission{ + { + Methods: manifest.WildStrings{Value: nil}, + }, + }, + }) + e.DeployContract(t, ctrB, nil) + + ctrInvoker := e.NewInvoker(ctrB.Hash, e.Committee) + h := ctrInvoker.Invoke(t, stackitem.Null{}, "callAInTryCatch") + aer := e.GetTxExecResult(t, h) + + require.Equal(t, 1, len(aer.Stack)) +} + +// This test is created to check https://github.com/neo-project/neo/pull/2755#discussion_r880087983. +func TestCALLL_from_VoidContext(t *testing.T) { + bc, acc := chain.NewSingle(t) + e := neotest.NewExecutor(t, bc, acc, acc) + + // Contract A has void method `CallHasRet` which calls non-void method `HasRet`. + srcA := `package contractA + func CallHasRet() { // Creates a context with non-nil onUnload. + HasRet() + } + func HasRet() int { // CALL_L clones parent context, check that onUnload is not cloned. + return 5 + }` + ctrA := neotest.CompileSource(t, acc.ScriptHash(), strings.NewReader(srcA), &compiler.Options{ + NoEventsCheck: true, + NoPermissionsCheck: true, + Name: "contractA", + }) + e.DeployContract(t, ctrA, nil) + + ctrInvoker := e.NewInvoker(ctrA.Hash, e.Committee) + ctrInvoker.Invoke(t, stackitem.Null{}, "callHasRet") +} diff --git a/pkg/core/native/designate.go b/pkg/core/native/designate.go index d4d92cdf6..e843031ef 100644 --- a/pkg/core/native/designate.go +++ b/pkg/core/native/designate.go @@ -15,7 +15,6 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/interop/runtime" "github.com/nspcc-dev/neo-go/pkg/core/native/nativenames" "github.com/nspcc-dev/neo-go/pkg/core/native/noderoles" - "github.com/nspcc-dev/neo-go/pkg/core/state" "github.com/nspcc-dev/neo-go/pkg/core/stateroot" "github.com/nspcc-dev/neo-go/pkg/core/storage" "github.com/nspcc-dev/neo-go/pkg/crypto/hash" @@ -386,14 +385,10 @@ func (s *Designate) DesignateAsRole(ic *interop.Context, r noderoles.Role, pubs return fmt.Errorf("failed to update Designation role data cache: %w", err) } - ic.Notifications = append(ic.Notifications, state.NotificationEvent{ - ScriptHash: s.Hash, - Name: DesignationEventName, - Item: stackitem.NewArray([]stackitem.Item{ - stackitem.NewBigInteger(big.NewInt(int64(r))), - stackitem.NewBigInteger(big.NewInt(int64(ic.Block.Index))), - }), - }) + ic.AddNotification(s.Hash, DesignationEventName, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(r))), + stackitem.NewBigInteger(big.NewInt(int64(ic.Block.Index))), + })) return nil } diff --git a/pkg/core/native/management.go b/pkg/core/native/management.go index 26c8b9add..92de9015d 100644 --- a/pkg/core/native/management.go +++ b/pkg/core/native/management.go @@ -607,12 +607,7 @@ func (m *Management) getNextContractID(d *dao.Simple) (int32, error) { } func (m *Management) emitNotification(ic *interop.Context, name string, hash util.Uint160) { - ne := state.NotificationEvent{ - ScriptHash: m.Hash, - Name: name, - Item: stackitem.NewArray([]stackitem.Item{addrToStackItem(&hash)}), - } - ic.Notifications = append(ic.Notifications, ne) + ic.AddNotification(m.Hash, name, stackitem.NewArray([]stackitem.Item{addrToStackItem(&hash)})) } func checkScriptAndMethods(script []byte, methods []manifest.Method) error { diff --git a/pkg/core/native/native_nep17.go b/pkg/core/native/native_nep17.go index 57538590e..e0963530e 100644 --- a/pkg/core/native/native_nep17.go +++ b/pkg/core/native/native_nep17.go @@ -165,16 +165,11 @@ func (c *nep17TokenNative) postTransfer(ic *interop.Context, from, to *util.Uint } func (c *nep17TokenNative) emitTransfer(ic *interop.Context, from, to *util.Uint160, amount *big.Int) { - ne := state.NotificationEvent{ - ScriptHash: c.Hash, - Name: "Transfer", - Item: stackitem.NewArray([]stackitem.Item{ - addrToStackItem(from), - addrToStackItem(to), - stackitem.NewBigInteger(amount), - }), - } - ic.Notifications = append(ic.Notifications, ne) + ic.AddNotification(c.Hash, "Transfer", stackitem.NewArray([]stackitem.Item{ + addrToStackItem(from), + addrToStackItem(to), + stackitem.NewBigInteger(amount), + })) } // updateAccBalance adds the specified amount to the acc's balance. If requiredBalance diff --git a/pkg/core/native/oracle.go b/pkg/core/native/oracle.go index 380f45e76..800423656 100644 --- a/pkg/core/native/oracle.go +++ b/pkg/core/native/oracle.go @@ -272,15 +272,11 @@ func (o *Oracle) FinishInternal(ic *interop.Context) error { if err != nil { return ErrRequestNotFound } + ic.AddNotification(o.Hash, "OracleResponse", stackitem.NewArray([]stackitem.Item{ + stackitem.Make(resp.ID), + stackitem.Make(req.OriginalTxID.BytesBE()), + })) - ic.Notifications = append(ic.Notifications, state.NotificationEvent{ - ScriptHash: o.Hash, - Name: "OracleResponse", - Item: stackitem.NewArray([]stackitem.Item{ - stackitem.Make(resp.ID), - stackitem.Make(req.OriginalTxID.BytesBE()), - }), - }) origTx, _, err := ic.DAO.GetTransaction(req.OriginalTxID) if err != nil { return ErrRequestNotFound @@ -382,16 +378,12 @@ func (o *Oracle) RequestInternal(ic *interop.Context, url string, filter *string } else { filterNotif = stackitem.Null{} } - ic.Notifications = append(ic.Notifications, state.NotificationEvent{ - ScriptHash: o.Hash, - Name: "OracleRequest", - Item: stackitem.NewArray([]stackitem.Item{ - stackitem.Make(id), - stackitem.Make(ic.VM.GetCallingScriptHash().BytesBE()), - stackitem.Make(url), - filterNotif, - }), - }) + ic.AddNotification(o.Hash, "OracleRequest", stackitem.NewArray([]stackitem.Item{ + stackitem.Make(id), + stackitem.Make(ic.VM.GetCallingScriptHash().BytesBE()), + stackitem.Make(url), + filterNotif, + })) req := &state.OracleRequest{ OriginalTxID: o.getOriginalTxID(ic.DAO, ic.Tx), GasForResponse: gas.Uint64(), diff --git a/pkg/neotest/client.go b/pkg/neotest/client.go index e35314655..513780d7f 100644 --- a/pkg/neotest/client.go +++ b/pkg/neotest/client.go @@ -109,8 +109,9 @@ func (c *ContractInvoker) InvokeWithFeeFail(t testing.TB, message string, sysFee // InvokeFail invokes the method with the args, persists the transaction and checks the error message. // It returns the transaction hash. -func (c *ContractInvoker) InvokeFail(t testing.TB, message string, method string, args ...interface{}) { +func (c *ContractInvoker) InvokeFail(t testing.TB, message string, method string, args ...interface{}) util.Uint256 { tx := c.PrepareInvoke(t, method, args...) c.AddNewBlock(t, tx) c.CheckFault(t, tx.Hash(), message) + return tx.Hash() } diff --git a/pkg/vm/context.go b/pkg/vm/context.go index 09c0ceaf5..c468e115e 100644 --- a/pkg/vm/context.go +++ b/pkg/vm/context.go @@ -54,8 +54,14 @@ type Context struct { NEF *nef.File // invTree is an invocation tree (or branch of it) for this context. invTree *InvocationTree + // onUnload is a callback that should be called after current context unloading + // if no exception occurs. + onUnload ContextUnloadCallback } +// ContextUnloadCallback is a callback method used on context unloading from istack. +type ContextUnloadCallback func(commit bool) error + var errNoInstParam = errors.New("failed to read instruction parameter") // NewContext returns a new Context object. @@ -316,3 +322,13 @@ func (v *VM) PushContextScriptHash(n int) error { v.Estack().PushItem(stackitem.NewByteArray(h.BytesBE())) return nil } + +func (c *Context) HasTryBlock() bool { + for i := 0; i < c.tryStack.Len(); i++ { + eCtx := c.tryStack.Peek(i).Value().(*exceptionHandlingContext) + if eCtx.State == eTry { + return true + } + } + return false +} diff --git a/pkg/vm/vm.go b/pkg/vm/vm.go index 44f905cbb..c9843a48d 100644 --- a/pkg/vm/vm.go +++ b/pkg/vm/vm.go @@ -285,7 +285,7 @@ func (v *VM) LoadScript(b []byte) { // LoadScriptWithFlags loads script and sets call flag to f. func (v *VM) LoadScriptWithFlags(b []byte, f callflag.CallFlag) { - v.loadScriptWithCallingHash(b, nil, v.GetCurrentScriptHash(), util.Uint160{}, f, -1, 0) + v.loadScriptWithCallingHash(b, nil, v.GetCurrentScriptHash(), util.Uint160{}, f, -1, 0, nil) } // LoadScriptWithHash is similar to the LoadScriptWithFlags method, but it also loads @@ -295,19 +295,19 @@ func (v *VM) LoadScriptWithFlags(b []byte, f callflag.CallFlag) { // accordingly). It's up to the user of this function to make sure the script and hash match // each other. func (v *VM) LoadScriptWithHash(b []byte, hash util.Uint160, f callflag.CallFlag) { - v.loadScriptWithCallingHash(b, nil, v.GetCurrentScriptHash(), hash, f, 1, 0) + v.loadScriptWithCallingHash(b, nil, v.GetCurrentScriptHash(), hash, f, 1, 0, nil) } // LoadNEFMethod allows to create a context to execute a method from the NEF // file with the specified caller and executing hash, call flags, return value, // method and _initialize offsets. func (v *VM) LoadNEFMethod(exe *nef.File, caller util.Uint160, hash util.Uint160, f callflag.CallFlag, - hasReturn bool, methodOff int, initOff int) { + hasReturn bool, methodOff int, initOff int, onContextUnload ContextUnloadCallback) { var rvcount int if hasReturn { rvcount = 1 } - v.loadScriptWithCallingHash(exe.Script, exe, caller, hash, f, rvcount, methodOff) + v.loadScriptWithCallingHash(exe.Script, exe, caller, hash, f, rvcount, methodOff, onContextUnload) if initOff >= 0 { v.Call(initOff) } @@ -316,7 +316,7 @@ func (v *VM) LoadNEFMethod(exe *nef.File, caller util.Uint160, hash util.Uint160 // loadScriptWithCallingHash is similar to LoadScriptWithHash but sets calling hash explicitly. // It should be used for calling from native contracts. func (v *VM) loadScriptWithCallingHash(b []byte, exe *nef.File, caller util.Uint160, - hash util.Uint160, f callflag.CallFlag, rvcount int, offset int) { + hash util.Uint160, f callflag.CallFlag, rvcount int, offset int, onContextUnload ContextUnloadCallback) { var sl slot v.checkInvocationStackSize() @@ -341,6 +341,7 @@ func (v *VM) loadScriptWithCallingHash(b []byte, exe *nef.File, caller util.Uint curTree.Calls = append(curTree.Calls, newTree) ctx.invTree = newTree } + ctx.onUnload = onContextUnload v.istack.PushItem(ctx) } @@ -1591,6 +1592,16 @@ func (v *VM) unloadContext(ctx *Context) { if ctx.static != nil && (currCtx == nil || ctx.static != currCtx.static) { ctx.static.ClearRefs(&v.refs) } + if ctx.onUnload != nil { + err := ctx.onUnload(v.uncaughtException == nil) + if err != nil { + errMessage := fmt.Sprintf("context unload callback failed: %s", err) + if v.uncaughtException != nil { + errMessage = fmt.Sprintf("%s, uncaught exception: %s", errMessage, v.uncaughtException) + } + panic(errors.New(errMessage)) + } + } } // getTryParams splits TRY(L) instruction parameter into offsets for catch and finally blocks. @@ -1647,6 +1658,8 @@ func (v *VM) call(ctx *Context, offset int) { newCtx.tryStack.elems = nil initStack(&newCtx.tryStack, "exception", nil) newCtx.NEF = ctx.NEF + // Do not clone unloading callback, new context does not require any actions to perform on unloading. + newCtx.onUnload = nil v.istack.PushItem(newCtx) newCtx.Jump(offset) }