diff --git a/cli/vm/cli.go b/cli/vm/cli.go index a06c1ed08..3f5eb5e2e 100644 --- a/cli/vm/cli.go +++ b/cli/vm/cli.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/elliptic" "encoding/base64" + "encoding/binary" "encoding/hex" "encoding/json" "errors" @@ -277,7 +278,8 @@ Example: "Hex-encoded storage items prefix may be specified (empty by default to return the whole set of storage items). " + "If seek prefix is not empty, then it's trimmed from the resulting keys." + "Items are sorted. Backwards seek direction may be specified (false by default, which means forwards storage seek direction). " + - "It is possible to dump only those storage items that were added or changed during current script invocation (use --diff flag for it).", + "It is possible to dump only those storage items that were added or changed during current script invocation (use --diff flag for it). " + + "To dump the whole set of storage changes including removed items use 'changes' command.", UsageText: `storage [] [--backwards] [--diff]`, Flags: []cli.Flag{ cli.BoolFlag{ @@ -286,7 +288,7 @@ Example: }, cli.BoolFlag{ Name: diffFlagFullName + ",d", - Usage: "Dump only those storage items that were added or changed during the current script invocation. Note that this call won't show removed storage items.", + Usage: "Dump only those storage items that were added or changed during the current script invocation. Note that this call won't show removed storage items, use 'changes' command for that.", }, }, Description: `storage [--backwards] [--diff] @@ -297,11 +299,32 @@ Hex-encoded storage items prefix may be specified (empty by default to return th If seek prefix is not empty, then it's trimmed from the resulting keys. Items are sorted. Backwards seek direction may be specified (false by default, which means forwards storage seek direction). It is possible to dump only those storage items that were added or changed during current script invocation (use --diff flag for it). +To dump the whole set of storage changes including removed items use 'changes' command. Example: > storage 0x0000000009070e030d0f0e020d0c06050e030c02 030e --backwards --diff`, Action: handleStorage, }, + { + Name: "changes", + Usage: "Dump storage changes as is at the current stage of loaded script invocation. " + + "If no script is loaded or executed, then no changes are present. " + + "The contract hash, address or ID may be specified as the first parameter to dump the specified contract storage changes. " + + "Hex-encoded search prefix (without contract ID) may be specified to dump matching storage changes. " + + "Resulting values are not sorted.", + UsageText: `changes [ []]`, + Description: `changes [ []] + +Dump storage changes as is at the current stage of loaded script invocation. +If no script is loaded or executed, then no changes are present. +The contract hash, address or ID may be specified as the first parameter to dump the specified contract storage changes. +Hex-encoded search prefix (without contract ID) may be specified to dump matching storage changes. +Resulting values are not sorted. + +Example: +> changes 0x0000000009070e030d0f0e020d0c06050e030c02 030e`, + Action: handleChanges, + }, } var completer *readline.PrefixCompleter @@ -907,37 +930,15 @@ func handleEnv(c *cli.Context) error { } func handleStorage(c *cli.Context) error { - if !c.Args().Present() { - return errors.New("contract hash, address or ID is mandatory argument") + id, prefix, err := getDumpArgs(c) + if err != nil { + return err } - hashOrID := c.Args().Get(0) var ( - id int32 - ic = getInteropContextFromContext(c.App) - prefix []byte backwards bool seekDepth int + ic = getInteropContextFromContext(c.App) ) - h, err := flags.ParseAddress(hashOrID) - if err != nil { - i, err := strconv.Atoi(hashOrID) - if err != nil { - return fmt.Errorf("failed to parse contract hash, address or ID: %w", err) - } - id = int32(i) - } else { - cs, err := ic.GetContract(h) - if err != nil { - return fmt.Errorf("contract %s not found: %w", h.StringLE(), err) - } - id = cs.ID - } - if c.NArg() > 1 { - prefix, err = hex.DecodeString(c.Args().Get(1)) - if err != nil { - return fmt.Errorf("failed to decode prefix from hex: %w", err) - } - } if c.Bool(backwardsFlagFullName) { backwards = true } @@ -955,6 +956,79 @@ func handleStorage(c *cli.Context) error { return nil } +func handleChanges(c *cli.Context) error { + var ( + expectedID int32 + prefix []byte + err error + hasAgs = c.Args().Present() + ) + if hasAgs { + expectedID, prefix, err = getDumpArgs(c) + if err != nil { + return err + } + } + ic := getInteropContextFromContext(c.App) + b := ic.DAO.GetBatch() + if b == nil { + return nil + } + ops := storage.BatchToOperations(b) + var notFirst bool + for _, op := range ops { + id := int32(binary.LittleEndian.Uint32(op.Key)) + if hasAgs && (expectedID != id || (len(prefix) != 0 && !bytes.HasPrefix(op.Key[4:], prefix))) { + continue + } + var message string + if notFirst { + message += "\n" + } + message += fmt.Sprintf("Contract ID: %d\nState: %s\nKey: %s\n", id, op.State, hex.EncodeToString(op.Key[4:])) + if op.Value != nil { + message += fmt.Sprintf("Value: %s\n", hex.EncodeToString(op.Value)) + } + fmt.Fprint(c.App.Writer, message) + notFirst = true + } + return nil +} + +// getDumpArgs is a helper function that retrieves contract ID and search prefix (if given). +func getDumpArgs(c *cli.Context) (int32, []byte, error) { + if !c.Args().Present() { + return 0, nil, errors.New("contract hash, address or ID is mandatory argument") + } + hashOrID := c.Args().Get(0) + var ( + ic = getInteropContextFromContext(c.App) + id int32 + prefix []byte + ) + h, err := flags.ParseAddress(hashOrID) + if err != nil { + i, err := strconv.Atoi(hashOrID) + if err != nil { + return 0, nil, fmt.Errorf("failed to parse contract hash, address or ID: %w", err) + } + id = int32(i) + } else { + cs, err := ic.GetContract(h) + if err != nil { + return 0, nil, fmt.Errorf("contract %s not found: %w", h.StringLE(), err) + } + id = cs.ID + } + if c.NArg() > 1 { + prefix, err = hex.DecodeString(c.Args().Get(1)) + if err != nil { + return 0, nil, fmt.Errorf("failed to decode prefix from hex: %w", err) + } + } + return id, prefix, nil +} + func dumpEvents(app *cli.App) (string, error) { ic := getInteropContextFromContext(app) if len(ic.Notifications) == 0 { diff --git a/cli/vm/cli_test.go b/cli/vm/cli_test.go index 13df18241..9fe83906d 100644 --- a/cli/vm/cli_test.go +++ b/cli/vm/cli_test.go @@ -23,6 +23,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/core/state" "github.com/nspcc-dev/neo-go/pkg/core/storage" "github.com/nspcc-dev/neo-go/pkg/core/storage/dbconfig" + "github.com/nspcc-dev/neo-go/pkg/core/storage/dboper" "github.com/nspcc-dev/neo-go/pkg/encoding/address" "github.com/nspcc-dev/neo-go/pkg/io" "github.com/nspcc-dev/neo-go/pkg/neotest" @@ -227,6 +228,20 @@ func (e *executor) checkStorage(t *testing.T, kvs ...storage.KeyValue) { } } +type storageChange struct { + ContractID int32 + dboper.Operation +} + +func (e *executor) checkChange(t *testing.T, c storageChange) { + e.checkNextLine(t, fmt.Sprintf("Contract ID: %d", c.ContractID)) + e.checkNextLine(t, fmt.Sprintf("State: %s", c.State)) + e.checkNextLine(t, fmt.Sprintf("Key: %s", hex.EncodeToString(c.Key))) + if c.Value != nil { + e.checkNextLine(t, fmt.Sprintf("Value: %s", hex.EncodeToString(c.Value))) + } +} + func (e *executor) checkSlot(t *testing.T, items ...interface{}) { d := json.NewDecoder(e.out) var actual interface{} @@ -910,3 +925,56 @@ func TestDumpStorageDiff(t *testing.T) { e.checkStorage(t, append(expected, diff)...) e.checkStorage(t, diff) } + +func TestDumpChanges(t *testing.T) { + e := newTestVMClIWithState(t) + + script := io.NewBufBinWriter() + h, err := e.cli.chain.GetContractScriptHash(1) // examples/storage/storage.go + require.NoError(t, err) + emit.AppCall(script.BinWriter, h, "put", callflag.All, 3, 4) // add + emit.AppCall(script.BinWriter, h, "delete", callflag.All, 1) // remove + emit.AppCall(script.BinWriter, h, "put", callflag.All, 2, 5) // update + + expected := []storageChange{ + { + ContractID: 1, + Operation: dboper.Operation{ + State: "Deleted", + Key: []byte{1}, + }, + }, + { + ContractID: 1, + Operation: dboper.Operation{ + State: "Changed", + Key: []byte{2}, + Value: []byte{5}, + }, + }, + { + ContractID: 1, + Operation: dboper.Operation{ + State: "Added", + Key: []byte{3}, + Value: []byte{4}, + }, + }, + } + e.runProg(t, + "changes", + "changes 1", + "loadhex "+hex.EncodeToString(script.Bytes()), + "run", + "changes 1 "+hex.EncodeToString([]byte{1}), + "changes 1 "+hex.EncodeToString([]byte{2}), + "changes 1 "+hex.EncodeToString([]byte{3}), + ) + + // no script is executed => no diff + e.checkNextLine(t, "READY: loaded 113 instructions") + e.checkStack(t, 3, true, 2) + e.checkChange(t, expected[0]) + e.checkChange(t, expected[1]) + e.checkChange(t, expected[2]) +}