Repository: nspcc-dev/neo-go
Pull request #3827: rpcsrv: add `SessionExpansionEnabled`
Author: login: AliceInHunterland
Created: 2025-03-05T01:22:57Z | Merged: 2025-03-18T11:41:05Z

Close #3818

Linked issue #3818: Implement RPC session iterator extensions
Implement https://github.com/neo-project/neo/issues/3429. Keep in mind that:
* It should be an RPC server extension, move it under some `SessionExpansionExtension` and ensure that compatibility is preserved with C# nodes and old NeoGo servers.
* Extend `getversion` response with this setting.
* Ensure RPC client and `unwrap` package can work with both `SessionExpansionExtension` enabled and disabled.
* Make sure Invoker can handle it properly.
Linked issue comments: user: login: roman-khimov
created_at: 2025-02-24T12:03:58Z
body: Most likely we'll need to release a version, upgrade all clients and only then enable it in production, because obviously current clients can be confused upon receiving expanded iterator with session data.
author_association: MEMBER

Diff:
diff --git a/docs/node-configuration.md b/docs/node-configuration.md
index 188f4b121c..08af261fc0 100644
--- a/docs/node-configuration.md
+++ b/docs/node-configuration.md
@@ -253,6 +253,7 @@ RPC:
   MaxWebSocketClients: 64
   MaxWebSocketFeeds: 16
   SessionEnabled: false
+  SessionExpansionEnabled: false
   SessionExpirationTime: 15
   SessionBackedByMPT: false
   SessionPoolSize: 20
@@ -314,6 +315,14 @@ where:
   requirement, then we recommend either to decrease `SessionExpirationTime` or to
   enable `SessionBackedByMPT`, see `SessionBackedByMPT` documentation for more
   details.
+- `SessionExpansionEnabled` enables partial expansion of iterators returned by 
+  `invoke*` calls. When enabled, the server returns up to `MaxIteratorResultItems`
+  values from the iterator in the initial response. If more items are available, 
+  the result is marked as truncated. When `SessionEnabled` is also true, the 
+  remaining iterator state is stored in a session, allowing further traversal 
+  via `traverseiterator`. If disabled, iterators are either expanded up to 
+  `MaxIteratorResultItems` or require explicit traversal via session-based calls.
+  By default, `SessionExpansionEnabled` is set to `false`.
 - `SessionExpirationTime` is a lifetime of iterator session in seconds. It is set
   to `TimePerBlock` seconds (but not less than 5s) by default and is relevant
   only if `SessionEnabled` is set to `true`.
diff --git a/docs/rpc.md b/docs/rpc.md
index 3f4d373775..633e765d3c 100644
--- a/docs/rpc.md
+++ b/docs/rpc.md
@@ -421,6 +421,17 @@ The invocation records are presented in a flat structure in the order as how the
 Note that invocation records for faulted transactions are kept and are present in the 
 applicationlog. This behaviour differs from notifications which are omitted for faulted transactions.
 
+#### Iterator sessions expansion
+
+The `SessionExpansionEnabled` configuration setting enforces RPC server to expand any iterator
+returned by `invoke*` calls. Specifically, up to `MaxIteratorResultItems` elements are 
+returned in the initial response, and the returned iterator is marked as `Truncated` if the 
+iterator contains more elements beyond the returned batch. In this case, if `SessionEnabled`
+is also `true`, an iterator `ID` and session `ID` is provided allowing clients to retrieve additional elements via
+`traverseiterator`. If SessionExpansionEnabled is false, the iterator behavior depends on 
+`SessionEnabled`: if `SessionEnabled` is `true`, a full iterator object with an ID is returned.
+If `SessionEnabled` is `false`, the iterator is expanded up to `MaxIteratorResultItems`.
+
 ## Reference
 
 * [JSON-RPC 2.0 Specification](http://www.jsonrpc.org/specification)
diff --git a/pkg/config/application_config.go b/pkg/config/application_config.go
index efbdc5d948..2b0e05dd5d 100644
--- a/pkg/config/application_config.go
+++ b/pkg/config/application_config.go
@@ -150,5 +150,8 @@ func (a *ApplicationConfiguration) Validate() error {
 	if err := a.NeoFSBlockFetcher.Validate(); err != nil {
 		return fmt.Errorf("invalid NeoFSBlockFetcher config: %w", err)
 	}
+	if err := a.RPC.Validate(); err != nil {
+		return fmt.Errorf("invalid RPC config: %w", err)
+	}
 	return nil
 }
diff --git a/pkg/config/rpc_config.go b/pkg/config/rpc_config.go
index 5187f58470..307d6e5dc3 100644
--- a/pkg/config/rpc_config.go
+++ b/pkg/config/rpc_config.go
@@ -1,6 +1,8 @@
 package config
 
 import (
+	"fmt"
+
 	"github.com/nspcc-dev/neo-go/pkg/encoding/fixedn"
 )
 
@@ -21,6 +23,7 @@ type (
 		MaxWebSocketClients       int           `yaml:"MaxWebSocketClients"`
 		MaxWebSocketFeeds         int           `yaml:"MaxWebSocketFeeds"`
 		SessionEnabled            bool          `yaml:"SessionEnabled"`
+		SessionExpansionEnabled   bool          `yaml:"SessionExpansionEnabled"`
 		SessionExpirationTime     int           `yaml:"SessionExpirationTime"`
 		SessionBackedByMPT        bool          `yaml:"SessionBackedByMPT"`
 		SessionPoolSize           int           `yaml:"SessionPoolSize"`
@@ -35,3 +38,12 @@ type (
 		KeyFile      string `yaml:"KeyFile"`
 	}
 )
+
+// Validate checks RPC for internal consistency. It returns an error if the
+// configuration is invalid.
+func (cfg *RPC) Validate() error {
+	if cfg.SessionExpansionEnabled && !cfg.SessionEnabled {
+		return fmt.Errorf("SessionExpansionEnabled requires SessionEnabled")
+	}
+	return nil
+}
diff --git a/pkg/core/interop/iterator/interop.go b/pkg/core/interop/iterator/interop.go
index 40607a9b9f..44d770e520 100644
--- a/pkg/core/interop/iterator/interop.go
+++ b/pkg/core/interop/iterator/interop.go
@@ -38,12 +38,21 @@ func IsIterator(item stackitem.Item) bool {
 
 // ValuesTruncated returns an array of up to `maxNum` iterator values. The second
 // return parameter denotes whether iterator is truncated, i.e. has more values.
-// The provided iterator CAN NOT be reused in the subsequent calls to Values and
-// to ValuesTruncated.
-func ValuesTruncated(item stackitem.Item, maxNum int) ([]stackitem.Item, bool) {
+// The third return parameter is the next value in the iterator. If the iterator
+// doesn't have more values, then third return parameter is nil. The iterator can
+// be reused for subsequent traversal, but `curr` will not be automatically
+// included in subsequent calls to Values or ValuesTruncated.
+func ValuesTruncated(item stackitem.Item, maxNum int) ([]stackitem.Item, bool, stackitem.Item) {
+	arr, ok := item.Value().(iterator)
+	if !ok {
+		return nil, false, nil
+	}
 	result := Values(item, maxNum)
-	arr := item.Value().(iterator)
-	return result, arr.Next()
+	if arr.Next() {
+		curr := arr.Value()
+		return result, true, curr
+	}
+	return result, false, nil
 }
 
 // Values returns an array of up to `maxNum` iterator values. The provided
diff --git a/pkg/neorpc/result/invoke.go b/pkg/neorpc/result/invoke.go
index 2fa32f4e44..4536a10795 100644
--- a/pkg/neorpc/result/invoke.go
+++ b/pkg/neorpc/result/invoke.go
@@ -55,17 +55,21 @@ type iteratorAux struct {
 	Truncated bool              `json:"truncated,omitempty"`
 }
 
-// Iterator represents VM iterator identifier. It either has ID set (for those JSON-RPC servers
-// that support sessions) or non-nil Values and Truncated set (for those JSON-RPC servers that
-// doesn't support sessions but perform in-place iterator traversing) or doesn't have ID, Values
-// and Truncated set at all (for those JSON-RPC servers that doesn't support iterator sessions
-// and doesn't perform in-place iterator traversing).
+// Iterator represents a VM iterator identifier. It can be in one of three
+// states: 1. If the JSON-RPC server supports session-based iterators, the
+// ID field is set, and the Values field may contain a partial expansion of
+// the iterator if session expansion is enabled. 2. If the JSON-RPC server
+// does not support sessions but allows in-place iteration, the Values field
+// will be populated, and Truncated will indicate whether more items exist.
+// 3. If the JSON-RPC server neither supports sessions nor in-place iteration,
+// all fields will be unset.
 type Iterator struct {
 	// ID represents iterator ID. It is non-nil iff JSON-RPC server support session mechanism.
 	ID *uuid.UUID
 
-	// Values contains deserialized VM iterator values with a truncated flag. It is non-nil
-	// iff JSON-RPC server does not support sessions mechanism and able to traverse iterator.
+	// Values contains deserialized VM iterator values with a truncated flag. It may be non-nil
+	// if session expansion is enabled (even when ID is set), or if the JSON-RPC server does not
+	// support sessions but allows in-place iterator traversal.
 	Values    []stackitem.Item
 	Truncated bool
 }
@@ -77,7 +81,8 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 	if r.ID != nil {
 		iaux.Interface = iteratorInterfaceName
 		iaux.ID = r.ID.String()
-	} else {
+	}
+	if r.Values != nil {
 		value := make([]json.RawMessage, len(r.Values))
 		for i := range r.Values {
 			var err error
@@ -87,8 +92,8 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 			}
 		}
 		iaux.Value = value
-		iaux.Truncated = r.Truncated
 	}
+	iaux.Truncated = r.Truncated
 	return json.Marshal(iaux)
 }
 
@@ -109,7 +114,8 @@ func (r *Iterator) UnmarshalJSON(data []byte) error {
 			return fmt.Errorf("failed to unmarshal iterator ID: %w", err)
 		}
 		r.ID = &iID
-	} else {
+	}
+	if iteratorAux.Value != nil {
 		r.Values = make([]stackitem.Item, len(iteratorAux.Value))
 		for j := range r.Values {
 			r.Values[j], err = stackitem.FromJSONWithTypes(iteratorAux.Value[j])
@@ -117,8 +123,8 @@ func (r *Iterator) UnmarshalJSON(data []byte) error {
 				return fmt.Errorf("failed to unmarshal iterator values: %w", err)
 			}
 		}
-		r.Truncated = iteratorAux.Truncated
 	}
+	r.Truncated = iteratorAux.Truncated
 	return nil
 }
 
diff --git a/pkg/neorpc/result/version.go b/pkg/neorpc/result/version.go
index cb33f1c400..7b28f9b1a9 100644
--- a/pkg/neorpc/result/version.go
+++ b/pkg/neorpc/result/version.go
@@ -27,6 +27,12 @@ type (
 	RPC struct {
 		MaxIteratorResultItems int  `json:"maxiteratorresultitems"`
 		SessionEnabled         bool `json:"sessionenabled"`
+
+		// Below are NeoGo-specific extensions to the JSON-RPC protocol that are
+		// returned by the server in case they're enabled.
+
+		// SessionExpansionEnabled is true if the server supports iterator expansion.
+		SessionExpansionEnabled bool `json:"sessionexpansionenabled,omitempty"`
 	}
 
 	// Protocol represents network-dependent parameters.
diff --git a/pkg/neorpc/result/version_test.go b/pkg/neorpc/result/version_test.go
index 162f92919b..7537ad9120 100644
--- a/pkg/neorpc/result/version_test.go
+++ b/pkg/neorpc/result/version_test.go
@@ -88,8 +88,9 @@ func TestVersion_MarshalUnmarshalJSON(t *testing.T) {
 		Nonce:     1677922561,
 		UserAgent: "/NEO-GO:0.98.6/",
 		RPC: RPC{
-			MaxIteratorResultItems: 100,
-			SessionEnabled:         true,
+			MaxIteratorResultItems:  100,
+			SessionEnabled:          true,
+			SessionExpansionEnabled: false,
 		},
 		Protocol: Protocol{
 			AddressVersion:              53,
diff --git a/pkg/rpcclient/invoker/invoker.go b/pkg/rpcclient/invoker/invoker.go
index ae7b6aa56e..c8872795e9 100644
--- a/pkg/rpcclient/invoker/invoker.go
+++ b/pkg/rpcclient/invoker/invoker.go
@@ -217,6 +217,8 @@ func termSession(rpc RPCSessions, sessionID uuid.UUID) error {
 // process. If num <= 0 then DefaultIteratorResultItems number of elements is
 // requested. If result contains no elements, then either Iterator has no
 // elements or session was expired and terminated by the server.
+// If server has SessionExpansionEnabled, then it's possible to have both
+// session-backed and expanded ones.
 func (v *Invoker) TraverseIterator(sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) {
 	return iterateNext(v.client, sessionID, iterator, num)
 }
@@ -226,12 +228,22 @@ func iterateNext(rpc RPCSessions, sessionID uuid.UUID, iterator *result.Iterator
 		num = DefaultIteratorResultItems
 	}
 
-	if iterator.ID != nil {
-		return rpc.TraverseIterator(sessionID, *iterator.ID, num)
+	var result []stackitem.Item
+
+	if iterator.Values != nil {
+		count := min(num, len(iterator.Values))
+		result = append(result, iterator.Values[:count]...)
+		iterator.Values = iterator.Values[count:]
+		num -= count
+	}
+
+	if num > 0 && iterator.ID != nil {
+		batch, err := rpc.TraverseIterator(sessionID, *iterator.ID, num)
+		if err != nil {
+			return nil, err
+		}
+		result = append(result, batch...)
 	}
-	num = min(num, len(iterator.Values))
-	items := iterator.Values[:num]
-	iterator.Values = iterator.Values[num:]
 
-	return items, nil
+	return result, nil
 }
diff --git a/pkg/rpcclient/invoker/invoker_test.go b/pkg/rpcclient/invoker/invoker_test.go
index 699a99fabc..4cc2656d1d 100644
--- a/pkg/rpcclient/invoker/invoker_test.go
+++ b/pkg/rpcclient/invoker/invoker_test.go
@@ -51,7 +51,13 @@ func (r *rpcInv) TerminateSession(sessionID uuid.UUID) (bool, error) {
 	return r.resTrm, r.err
 }
 func (r *rpcInv) TraverseIterator(sessionID, iteratorID uuid.UUID, maxItemsCount int) ([]stackitem.Item, error) {
-	return r.resItm, r.err
+	if r.err != nil {
+		return nil, r.err
+	}
+	maxItemsCount = min(maxItemsCount, len(r.resItm))
+	items := r.resItm[:maxItemsCount]
+	r.resItm = r.resItm[maxItemsCount:]
+	return items, nil
 }
 
 func TestInvoker(t *testing.T) {
@@ -157,6 +163,40 @@ func TestInvoker(t *testing.T) {
 			require.Equal(t, []stackitem.Item{stackitem.Make(42)}, res)
 		}
 	})
+	t.Run("traverse iterator with session expansion", func(t *testing.T) {
+		mockClient := &rpcInv{
+			resItm: []stackitem.Item{
+				stackitem.Make(1),
+				stackitem.Make(2),
+				stackitem.Make(3),
+			},
+			err: nil,
+		}
+		inv := New(mockClient, nil)
+
+		sessionID := uuid.New()
+		iteratorID := uuid.New()
+		iter := &result.Iterator{
+			ID:     &iteratorID,
+			Values: []stackitem.Item{stackitem.Make(10), stackitem.Make(20)},
+		}
+		res, err := inv.TraverseIterator(sessionID, iter, 2)
+		require.NoError(t, err)
+		require.Equal(t, []stackitem.Item{stackitem.Make(10), stackitem.Make(20)}, res)
+
+		res, err = inv.TraverseIterator(sessionID, iter, 2)
+		require.NoError(t, err)
+		require.Equal(t, []stackitem.Item{stackitem.Make(1), stackitem.Make(2)}, res)
+
+		res, err = inv.TraverseIterator(sessionID, iter, 2)
+		require.NoError(t, err)
+		require.Equal(t, []stackitem.Item{stackitem.Make(3)}, res)
+
+		mockClient.resItm = nil
+		res, err = inv.TraverseIterator(sessionID, iter, 2)
+		require.NoError(t, err)
+		require.Empty(t, res)
+	})
 }
 
 func TestInvokerSigners(t *testing.T) {
diff --git a/pkg/rpcclient/unwrap/unwrap_test.go b/pkg/rpcclient/unwrap/unwrap_test.go
index 7962240e5d..03553e3290 100644
--- a/pkg/rpcclient/unwrap/unwrap_test.go
+++ b/pkg/rpcclient/unwrap/unwrap_test.go
@@ -309,6 +309,12 @@ func TestSessionIterator(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, sid, rs)
 	require.Equal(t, iter, ri)
+
+	iterExpanded := result.Iterator{Values: []stackitem.Item{stackitem.Make(1), stackitem.Make(2)}, Truncated: true}
+	rs, ri, err = SessionIterator(&result.Invoke{State: "HALT", Stack: []stackitem.Item{stackitem.NewInterop(iterExpanded)}}, nil)
+	require.NoError(t, err)
+	require.Empty(t, rs)
+	require.Equal(t, iterExpanded, ri)
 }
 
 func TestArraySessionIterator(t *testing.T) {
diff --git a/pkg/services/rpcsrv/client_test.go b/pkg/services/rpcsrv/client_test.go
index 30d4bc7dcd..16a8732c64 100644
--- a/pkg/services/rpcsrv/client_test.go
+++ b/pkg/services/rpcsrv/client_test.go
@@ -1450,9 +1450,6 @@ func TestClient_IteratorSessions(t *testing.T) {
 	t.Cleanup(c.Close)
 	require.NoError(t, c.Init())
 
-	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
-	require.NoError(t, err)
-
 	// storageItemsCount is the amount of storage items stored in Storage contract, it's hard-coded in the contract code.
 	const storageItemsCount = 255
 	expected := make([][]byte, storageItemsCount)
@@ -1658,8 +1655,6 @@ func TestClientOracle(t *testing.T) {
 
 func TestClient_Iterator_SessionConfigVariations(t *testing.T) {
 	var expected [][]byte
-	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
-	require.NoError(t, err)
 	// storageItemsCount is the amount of storage items stored in Storage contract, it's hard-coded in the contract code.
 	const storageItemsCount = 255
 
@@ -2523,3 +2518,186 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestClient_SessionExpasionExtension(t *testing.T) {
+	const (
+		// storageItemsCount is the amount of storage items stored in Storage contract,
+		// it's hard-coded in the contract code.
+		storageItemsCount              = 255
+		expectedMaxIteratorResultItems = 50
+		batchSize                      = 33
+	)
+
+	expected := make([][]byte, storageItemsCount)
+	for i := range storageItemsCount {
+		expected[i] = stackitem.NewBigInteger(big.NewInt(int64(i))).Bytes()
+	}
+	slices.SortFunc(expected, func(a, b []byte) int {
+		if len(a) != len(b) {
+			return len(a) - len(b)
+		}
+		return bytes.Compare(a, b)
+	})
+	setup := func(t *testing.T, cfgMod func(*config.Config)) *rpcclient.Client {
+		chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, cfgMod)
+		t.Cleanup(rpcSrv.Shutdown)
+		t.Cleanup(httpSrv.Close)
+
+		for _, b := range getTestBlocks(t) {
+			require.NoError(t, chain.AddBlock(b))
+		}
+
+		c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+		require.NoError(t, err)
+		require.NoError(t, c.Init())
+		t.Cleanup(c.Close)
+
+		return c
+	}
+	prepareSession := func(t *testing.T, c *rpcclient.Client) (uuid.UUID, uuid.UUID, []stackitem.Item) {
+		res, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.NotEmpty(t, res.Session)
+		require.Equal(t, 1, len(res.Stack))
+		require.Equal(t, stackitem.InteropT, res.Stack[0].Type())
+		iterator, ok := res.Stack[0].Value().(result.Iterator)
+		require.True(t, ok)
+		require.NotEmpty(t, iterator.ID)
+		return res.Session, *iterator.ID, iterator.Values
+	}
+	t.Run("session disabled", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+		invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+		require.NotEmpty(t, invokeRes.Script)
+		require.Empty(t, invokeRes.Session)
+
+		require.Len(t, invokeRes.Stack, 1)
+		stackItem := invokeRes.Stack[0]
+		require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+		iterVal, ok := stackItem.Value().(result.Iterator)
+		require.True(t, ok)
+
+		require.Equal(t, expectedMaxIteratorResultItems, len(iterVal.Values))
+		for i := range len(iterVal.Values) {
+			require.Equal(t, expected[i], iterVal.Values[i].Value().([]byte), i)
+		}
+		require.True(t, iterVal.Truncated)
+		require.Empty(t, iterVal.ID)
+	})
+	t.Run("session disabled and session expansion disabled", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = false
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+
+		invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+		require.NotEmpty(t, invokeRes.Script)
+		require.Empty(t, invokeRes.Session)
+
+		require.Len(t, invokeRes.Stack, 1)
+		stackItem := invokeRes.Stack[0]
+		require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+		iterVal, ok := stackItem.Value().(result.Iterator)
+		require.True(t, ok)
+
+		require.Equal(t, expectedMaxIteratorResultItems, len(iterVal.Values))
+		for i := range len(iterVal.Values) {
+			require.Equal(t, expected[i], iterVal.Values[i].Value().([]byte), i)
+		}
+		require.True(t, iterVal.Truncated)
+		require.Empty(t, iterVal.ID)
+	})
+	t.Run("session enabled and SessionBackedByMPT", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+			cfg.ApplicationConfiguration.RPC.SessionBackedByMPT = true
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+
+		sID, iID, curr := prepareSession(t, c)
+		require.Equal(t, len(curr), expectedMaxIteratorResultItems)
+		for i := range curr {
+			require.Equal(t, expected[i], curr[i].Value().([]byte), i)
+		}
+
+		var batchCount int
+		for {
+			batch, err := c.TraverseIterator(sID, iID, batchSize)
+			require.NoError(t, err)
+
+			if len(batch) == 0 {
+				break
+			}
+			for i := range batch {
+				require.Equal(t, expected[expectedMaxIteratorResultItems+batchCount*batchSize+i], batch[i].Value().([]byte), i)
+			}
+			batchCount++
+		}
+		require.Equal(t, (storageItemsCount-expectedMaxIteratorResultItems)/batchSize+1, batchCount)
+	})
+
+	t.Run("session enabled", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+
+		sID, iID, localItems := prepareSession(t, c)
+		require.Equal(t, expectedMaxIteratorResultItems, len(localItems))
+		for i := range localItems {
+			require.Equal(t, expected[i], localItems[i].Value().([]byte))
+		}
+
+		firstBatch, err := c.TraverseIterator(sID, iID, 20)
+		require.NoError(t, err)
+		require.Len(t, firstBatch, 20)
+		for i := range firstBatch {
+			require.Equal(t, expected[expectedMaxIteratorResultItems+i], firstBatch[i].Value().([]byte))
+		}
+
+		secondBatch, err := c.TraverseIterator(sID, iID, 20)
+		require.NoError(t, err)
+		require.Len(t, secondBatch, 20)
+		for i := range secondBatch {
+			require.Equal(t, expected[expectedMaxIteratorResultItems+20+i], secondBatch[i].Value().([]byte))
+		}
+
+		thirdBatch, err := c.TraverseIterator(sID, iID, 25)
+		require.NoError(t, err)
+		require.Len(t, thirdBatch, 25)
+		for i := range thirdBatch {
+			require.Equal(t, expected[expectedMaxIteratorResultItems+20+20+i], thirdBatch[i].Value().([]byte))
+		}
+
+		idx := expectedMaxIteratorResultItems + 20 + 20 + 25
+		for {
+			batch, err := c.TraverseIterator(sID, iID, batchSize)
+			require.NoError(t, err)
+			if len(batch) == 0 {
+				break
+			}
+			for i := range batch {
+				require.Equal(t, expected[idx+i], batch[i].Value().([]byte))
+			}
+			idx += len(batch)
+		}
+		require.Equal(t, storageItemsCount, idx)
+	})
+}
diff --git a/pkg/services/rpcsrv/server.go b/pkg/services/rpcsrv/server.go
index 2f5f3325b3..5c2f0d4c8f 100644
--- a/pkg/services/rpcsrv/server.go
+++ b/pkg/services/rpcsrv/server.go
@@ -185,6 +185,10 @@ type (
 		ID string
 		// Item represents Iterator stackitem.
 		Item stackitem.Item
+		// Curr represents current Iterator value got after initial iterator traversal if in-place iterator
+		// traversal is performed. This value must be used to prepend to the resulting items list during
+		// the subsequent iterator traversal.
+		Curr stackitem.Item
 	}
 )
 
@@ -882,8 +886,9 @@ func (s *Server) getVersion(_ params.Params) (any, *neorpc.Error) {
 		Nonce:     s.coreServer.ID(),
 		UserAgent: s.coreServer.UserAgent,
 		RPC: result.RPC{
-			MaxIteratorResultItems: s.config.MaxIteratorResultItems,
-			SessionEnabled:         s.config.SessionEnabled,
+			MaxIteratorResultItems:  s.config.MaxIteratorResultItems,
+			SessionEnabled:          s.config.SessionEnabled,
+			SessionExpansionEnabled: s.config.SessionExpansionEnabled,
 		},
 		Protocol: result.Protocol{
 			AddressVersion:              address.NEO3Prefix,
@@ -2487,13 +2492,17 @@ func (s *Server) postProcessExecStack(stack []stackitem.Item) *session {
 	var sess session
 
 	for i, v := range stack {
-		var id uuid.UUID
+		var (
+			id   uuid.UUID
+			curr stackitem.Item
+		)
 
-		stack[i], id = s.registerOrDumpIterator(v)
+		stack[i], id, curr = s.registerOrDumpIterator(v)
 		if id != (uuid.UUID{}) {
 			sess.iteratorIdentifiers = append(sess.iteratorIdentifiers, &iteratorIdentifier{
 				ID:   id.String(),
 				Item: v,
+				Curr: curr,
 			})
 		}
 	}
@@ -2505,22 +2514,29 @@ func (s *Server) postProcessExecStack(stack []stackitem.Item) *session {
 
 // registerOrDumpIterator changes iterator interop stack items into result.Iterator
 // interop stack items and returns a uuid for it if sessions are enabled. All the other stack
-// items are not changed.
-func (s *Server) registerOrDumpIterator(item stackitem.Item) (stackitem.Item, uuid.UUID) {
+// items are not changed. The third return value is the current iterator value if it's not nil.
+func (s *Server) registerOrDumpIterator(item stackitem.Item) (stackitem.Item, uuid.UUID, stackitem.Item) {
 	var iterID uuid.UUID
 
 	if (item.Type() != stackitem.InteropT) || !iterator.IsIterator(item) {
-		return item, iterID
+		return item, iterID, nil
 	}
-	var resIterator result.Iterator
+
+	var (
+		resIterator result.Iterator
+		curr        stackitem.Item
+	)
 
 	if s.config.SessionEnabled {
 		iterID = uuid.New()
 		resIterator.ID = &iterID
+		if s.config.SessionExpansionEnabled {
+			resIterator.Values, resIterator.Truncated, curr = iterator.ValuesTruncated(item, s.config.MaxIteratorResultItems)
+		}
 	} else {
-		resIterator.Values, resIterator.Truncated = iterator.ValuesTruncated(item, s.config.MaxIteratorResultItems)
+		resIterator.Values, resIterator.Truncated, curr = iterator.ValuesTruncated(item, s.config.MaxIteratorResultItems)
 	}
-	return stackitem.NewInterop(resIterator), iterID
+	return stackitem.NewInterop(resIterator), iterID, curr
 }
 
 func (s *Server) traverseIterator(reqParams params.Params) (any, *neorpc.Error) {
@@ -2565,8 +2581,16 @@ func (s *Server) traverseIterator(reqParams params.Params) (any, *neorpc.Error)
 	)
 	for _, it := range session.iteratorIdentifiers {
 		if iIDStr == it.ID {
-			iVals = iterator.Values(it.Item, count)
 			found = true
+			if it.Curr != nil {
+				if count <= 0 {
+					break
+				}
+				iVals = append(iVals, it.Curr)
+				it.Curr = nil
+				count--
+			}
+			iVals = append(iVals, iterator.Values(it.Item, count)...)
 			break
 		}
 	}
diff --git a/pkg/services/rpcsrv/server_test.go b/pkg/services/rpcsrv/server_test.go
index dcb0260875..c93878d234 100644
--- a/pkg/services/rpcsrv/server_test.go
+++ b/pkg/services/rpcsrv/server_test.go
@@ -133,6 +133,10 @@ var (
 	// nnsHash is a hash of NEP-11 non-divisible "examples/nft-nd-nns" contract
 	// deployed at block #11 of basic testing chain.
 	nnsHash, _ = util.Uint160DecodeStringLE(nnsContractHash)
+	// storageHash is a hash of "Storage" contract
+	// placed in "internal/basicchain/testdata/storage/storage_contract.go"
+	// deployed at block #22 of basic testing chain.
+	storageHash, _ = util.Uint160DecodeStringLE(storageContractHash)
 	// nfsoHash is a hash of NEP-11 divisible "examples/nft-d" ("NeoFS
 	// Object") contract deployed at block #17 of basic testing chain.
 	nfsoHash, _ = util.Uint160DecodeStringLE(nfsoContractHash)
@@ -3213,6 +3217,35 @@ func testRPCProtocol(t *testing.T, doRPCCall func(string, string, *testing.T) []
 			body := doRPCCall(rpc, httpSrv.URL, t)
 			checkErrGetResult(t, body, true, neorpc.ErrUnknownIteratorCode)
 		})
+		t.Run("sessions expansion enabled", func(t *testing.T) {
+			chain2, _, httpSrv2 := initClearServerWithCustomConfig(t, func(c *config.Config) {
+				c.ApplicationConfiguration.RPC.SessionEnabled = true
+				c.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			})
+			for _, b := range getTestBlocks(t) {
+				require.NoError(t, chain2.AddBlock(b))
+			}
+			rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "invokefunction", "params": ["%s", "iterateOverValues"]}"`, storageContractHash)
+			body := doRPCCall(rpc, httpSrv2.URL, t)
+			resp := checkErrGetResult(t, body, false, 0)
+			res := new(result.Invoke)
+			err := json.Unmarshal(resp, &res)
+			require.NoErrorf(t, err, "could not parse response: %s", resp)
+			require.NotEmpty(t, res.Session)
+			require.Equal(t, 1, len(res.Stack))
+			require.Equal(t, stackitem.InteropT, res.Stack[0].Type())
+			iterator, ok := res.Stack[0].Value().(result.Iterator)
+			require.True(t, ok)
+			require.NotEmpty(t, iterator.ID)
+
+			expectedCount := 0
+			rpc = fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "traverseiterator", "params": ["%s", "%s", %d]}"`, res.Session.String(), iterator.ID.String(), expectedCount)
+			body = doRPCCall(rpc, httpSrv2.URL, t)
+			resp = checkErrGetResult(t, body, false, 0)
+			resResp := new([]json.RawMessage)
+			require.NoError(t, json.Unmarshal(resp, resResp))
+			require.Equal(t, expectedCount, len(*resResp))
+		})
 	})
 	t.Run("terminatesession", func(t *testing.T) {
 		rpc := `{"jsonrpc": "2.0", "id": 1, "method": "terminatesession", "params": ["%s"]}"`


Reviews:
user: login: AnnaShaleva
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-05T16:26:17Z
user: login: AnnaShaleva
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T16:41:50Z
user: login: AliceInHunterland
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T06:10:25Z
user: login: AliceInHunterland
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T06:10:53Z
user: login: AnnaShaleva
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-10T11:10:17Z
user: login: AnnaShaleva
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T11:10:36Z
user: login: AnnaShaleva
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T14:16:34Z
user: login: AliceInHunterland
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T07:15:54Z
user: login: AnnaShaleva
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T10:59:08Z
user: login: AnnaShaleva
body: Otherwise LGTM.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T08:48:27Z
user: login: AnnaShaleva
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T10:31:57Z
user: login: AliceInHunterland
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:23:08Z
user: login: AnnaShaleva
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T11:40:33Z

Review comments:
diff_hunk: @@ -20,6 +20,7 @@ type (
 		MaxRequestHeaderBytes     int           `yaml:"MaxRequestHeaderBytes"`
 		MaxWebSocketClients       int           `yaml:"MaxWebSocketClients"`
 		SessionEnabled            bool          `yaml:"SessionEnabled"`
+		SessionExpansionEnabled   bool          `yaml:"SessionExpansionExtension"`
path: pkg/config/rpc_config.go
user: login: AnnaShaleva
body: I like `SessionExpansionEnabled` name, it's better than the original proposal. Let's rename YML field as far.
created_at: 2025-03-05T15:05:46Z
author_association: MEMBER
diff_hunk: @@ -25,8 +25,9 @@ type (
 
 	// RPC represents the RPC server configuration.
 	RPC struct {
-		MaxIteratorResultItems int  `json:"maxiteratorresultitems"`
-		SessionEnabled         bool `json:"sessionenabled"`
+		MaxIteratorResultItems  int  `json:"maxiteratorresultitems"`
+		SessionEnabled          bool `json:"sessionenabled"`
+		SessionExpansionEnabled bool `json:"sessionexpansionenabled"`
path: pkg/neorpc/result/version.go
user: login: AnnaShaleva
body: It's an extension, so let's split it from the default RPC options with a separate line and comment. Take as an example how it is done for `Protocol` part: `Below are NeoGo-specific extensions to the protocol that are...`
created_at: 2025-03-05T15:08:09Z
author_association: MEMBER
diff_hunk: @@ -47,7 +47,8 @@ func TestVersion_MarshalUnmarshalJSON(t *testing.T) {
         },
         "rpc": {
             "maxiteratorresultitems": 100,
-            "sessionenabled": true
+            "sessionenabled": true,
+			"sessionexpansionenabled": false
path: pkg/neorpc/result/version_test.go
user: login: AnnaShaleva
body: Indent.
created_at: 2025-03-05T15:08:25Z
author_association: MEMBER
diff_hunk: @@ -2510,11 +2511,19 @@ func (s *Server) registerOrDumpIterator(item stackitem.Item) (stackitem.Item, uu
 	}
 	var resIterator result.Iterator
 
-	if s.config.SessionEnabled {
-		iterID = uuid.New()
-		resIterator.ID = &iterID
-	} else {
+	if s.config.SessionExpansionEnabled {
 		resIterator.Values, resIterator.Truncated = iterator.ValuesTruncated(item, s.config.MaxIteratorResultItems)
path: pkg/services/rpcsrv/server.go
user: login: AnnaShaleva
body: It won't work. First of all, create an end-to-end test in `services/rpcsrv/client_test.go`. We have some iterator-based contracts (e.g. `storage` contract) deployed to the Basic chain, take a look at them and check if they contain enough storage items (we'll need at least `MaxIteratorResultItems+2`). You may add some more elements to the contract storage if needed. In this test enable `SessionExpansionEnabled` and check that some elements are missing from the resulting iterator value.

Read the documentation of `iterator.ValuesTruncated` and check the implementation, it is not designated to be reused because of the way how `truncated` is defined:
https://github.com/nspcc-dev/neo-go/blob/68d7e8e01cd8d4e5c8f35bb05949c5dc4718b18e/pkg/core/interop/iterator/interop.go#L46

You need to store element accessed by `arr.Next()` in the session context, because otherwise this element can't be recovered.

Also, it seems that we need a special care for MPT-backed sessions, but it needs to be checked.
created_at: 2025-03-05T16:26:06Z
author_association: MEMBER
diff_hunk: @@ -20,6 +20,7 @@ type (
 		MaxRequestHeaderBytes     int           `yaml:"MaxRequestHeaderBytes"`
 		MaxWebSocketClients       int           `yaml:"MaxWebSocketClients"`
 		SessionEnabled            bool          `yaml:"SessionEnabled"`
+		SessionExpansionEnabled   bool          `yaml:"SessionExpansionExtension"`
path: pkg/config/rpc_config.go
user: login: AnnaShaleva
body: Don't forget to update https://github.com/nspcc-dev/neo-go/blob/master/docs/node-configuration.md#rpc-configuration and RPC extensions section.
created_at: 2025-03-05T16:41:48Z
author_association: MEMBER
diff_hunk: @@ -119,6 +131,17 @@ func (r *Iterator) UnmarshalJSON(data []byte) error {
 		}
 		r.Truncated = iteratorAux.Truncated
 	}
+	if len(iteratorAux.Value) != 0 {
+		fmt.Println("AAAA")
path: pkg/neorpc/result/invoke.go
user: login: AliceInHunterland
body: print
created_at: 2025-03-10T06:10:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,6 +46,25 @@ func ValuesTruncated(item stackitem.Item, maxNum int) ([]stackitem.Item, bool) {
 	return result, arr.Next()
 }
 
+func PartialExpand(item stackitem.Item, maxNum int) ([]stackitem.Item, bool, stackitem.Item) {
path: pkg/core/interop/iterator/interop.go
user: login: AliceInHunterland
body: comments
created_at: 2025-03-10T06:10:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -89,6 +89,18 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 		iaux.Value = value
 		iaux.Truncated = r.Truncated
 	}
+	if len(r.Values) != 0 {
+		value := make([]json.RawMessage, len(r.Values))
+		for i := range r.Values {
+			var err error
+			value[i], err = stackitem.ToJSONWithTypes(r.Values[i])
+			if err != nil {
+				return nil, err
+			}
+		}
+		iaux.Value = value
+		iaux.Truncated = r.Truncated
+	}
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: This code must be unified with the existing code without copy-pasting.
created_at: 2025-03-10T09:39:12Z
author_association: MEMBER
diff_hunk: @@ -185,6 +185,8 @@ type (
 		ID string
 		// Item represents Iterator stackitem.
 		Item stackitem.Item
+
+		Leftover stackitem.Item
path: pkg/services/rpcsrv/server.go
user: login: AnnaShaleva
body: Just `Curr`, ditto everywhere.
created_at: 2025-03-10T10:06:16Z
author_association: MEMBER
diff_hunk: @@ -185,6 +185,8 @@ type (
 		ID string
 		// Item represents Iterator stackitem.
 		Item stackitem.Item
+
+		Leftover stackitem.Item
path: pkg/services/rpcsrv/server.go
user: login: AnnaShaleva
body: And it needs a very good comment about the reason it exists and about its behaviour, because we have different modes (MPT-backed vs ordinary sessions) and behaviour of `Curr` is different in this case.
created_at: 2025-03-10T10:07:36Z
author_association: MEMBER
diff_hunk: @@ -46,6 +46,25 @@ func ValuesTruncated(item stackitem.Item, maxNum int) ([]stackitem.Item, bool) {
 	return result, arr.Next()
 }
 
+func PartialExpand(item stackitem.Item, maxNum int) ([]stackitem.Item, bool, stackitem.Item) {
path: pkg/core/interop/iterator/interop.go
user: login: AnnaShaleva
body: You don't need this, there's a lot of copy-pasting in your code. Try to reuse the existing functionality where possible. For this particular case extend `ValuesTruncated` to return the third value (the value of iterator) if `arr.Next` is `true`, that's it. Adjust the method's comment to reflect these changes.
created_at: 2025-03-10T10:37:13Z
author_association: MEMBER
diff_hunk: @@ -2523,3 +2523,105 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension_SessionDisabled(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 5
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+	require.NoError(t, err)
+	require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+	require.NotEmpty(t, invokeRes.Script)
+
+	require.Len(t, invokeRes.Stack, 1)
+	stackItem := invokeRes.Stack[0]
+	require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+	iterVal, ok := stackItem.Value().(result.Iterator)
+	require.True(t, ok)
+
+	require.Equal(t, len(iterVal.Values), 5)
+	require.True(t, iterVal.Truncated)
+}
+
+func TestSessionExpansionExtension_E2E2(t *testing.T) {
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: Evevery test in this file is `E2E`, remove it. Make the name reflect the options you're testing.
created_at: 2025-03-10T10:41:49Z
author_association: MEMBER
diff_hunk: @@ -2523,3 +2523,105 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension_SessionDisabled(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 5
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+	require.NoError(t, err)
+	require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+	require.NotEmpty(t, invokeRes.Script)
+
+	require.Len(t, invokeRes.Stack, 1)
+	stackItem := invokeRes.Stack[0]
+	require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+	iterVal, ok := stackItem.Value().(result.Iterator)
+	require.True(t, ok)
+
+	require.Equal(t, len(iterVal.Values), 5)
+	require.True(t, iterVal.Truncated)
+}
+
+func TestSessionExpansionExtension_E2E2(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 50
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	prepareSession := func(t *testing.T) (uuid.UUID, uuid.UUID, []stackitem.Item) {
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: It's used once, no need to declare a separate function for it.
created_at: 2025-03-10T10:43:11Z
author_association: MEMBER
diff_hunk: @@ -2523,3 +2523,105 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension_SessionDisabled(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 5
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+	require.NoError(t, err)
+	require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+	require.NotEmpty(t, invokeRes.Script)
+
+	require.Len(t, invokeRes.Stack, 1)
+	stackItem := invokeRes.Stack[0]
+	require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+	iterVal, ok := stackItem.Value().(result.Iterator)
+	require.True(t, ok)
+
+	require.Equal(t, len(iterVal.Values), 5)
+	require.True(t, iterVal.Truncated)
+}
+
+func TestSessionExpansionExtension_E2E2(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 50
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	prepareSession := func(t *testing.T) (uuid.UUID, uuid.UUID, []stackitem.Item) {
+		res, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.NotEmpty(t, res.Session)
+		require.Equal(t, 1, len(res.Stack))
+		require.Equal(t, stackitem.InteropT, res.Stack[0].Type())
+		iterator, ok := res.Stack[0].Value().(result.Iterator)
+		require.True(t, ok)
+		require.NotEmpty(t, iterator.ID)
+		return res.Session, *iterator.ID, iterator.Values
+	}
+
+	sID, iID, leftover := prepareSession(t)
+	batchSize := 30
+	allItems := make([]stackitem.Item, 0, 256)
+	allItems = append(allItems, leftover...)
+	for {
+		batch, err := c.TraverseIterator(sID, iID, batchSize)
+		require.NoError(t, err)
+
+		if len(batch) == 0 {
+			break
+		}
+		allItems = append(allItems, batch...)
+	}
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: It's a test, you need to check that the number of batches matches expectations.
created_at: 2025-03-10T10:45:18Z
author_association: MEMBER
diff_hunk: @@ -2523,3 +2523,105 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension_SessionDisabled(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 5
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+	require.NoError(t, err)
+	require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+	require.NotEmpty(t, invokeRes.Script)
+
+	require.Len(t, invokeRes.Stack, 1)
+	stackItem := invokeRes.Stack[0]
+	require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+	iterVal, ok := stackItem.Value().(result.Iterator)
+	require.True(t, ok)
+
+	require.Equal(t, len(iterVal.Values), 5)
+	require.True(t, iterVal.Truncated)
+}
+
+func TestSessionExpansionExtension_E2E2(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 50
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	prepareSession := func(t *testing.T) (uuid.UUID, uuid.UUID, []stackitem.Item) {
+		res, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.NotEmpty(t, res.Session)
+		require.Equal(t, 1, len(res.Stack))
+		require.Equal(t, stackitem.InteropT, res.Stack[0].Type())
+		iterator, ok := res.Stack[0].Value().(result.Iterator)
+		require.True(t, ok)
+		require.NotEmpty(t, iterator.ID)
+		return res.Session, *iterator.ID, iterator.Values
+	}
+
+	sID, iID, leftover := prepareSession(t)
+	batchSize := 30
+	allItems := make([]stackitem.Item, 0, 256)
+	allItems = append(allItems, leftover...)
+	for {
+		batch, err := c.TraverseIterator(sID, iID, batchSize)
+		require.NoError(t, err)
+
+		if len(batch) == 0 {
+			break
+		}
+		allItems = append(allItems, batch...)
+	}
+	const storageItemsCount = 255
+	expected := make([][]byte, storageItemsCount)
+	for i := range storageItemsCount {
+		expected[i] = stackitem.NewBigInteger(big.NewInt(int64(i))).Bytes()
+	}
+	slices.SortFunc(expected, func(a, b []byte) int {
+		if len(a) != len(b) {
+			return len(a) - len(b)
+		}
+		return bytes.Compare(a, b)
+	})
+
+	for i := range len(allItems) {
+		require.Equal(t, expected[i], allItems[i].Value().([]byte), i)
+	}
+}
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: It's an additional overhead. Iterator must return sorted results, so I suggest to check the content of batches in-place without allocating `allItems` slice.
created_at: 2025-03-10T10:46:20Z
author_association: MEMBER
diff_hunk: @@ -2523,3 +2523,105 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension_SessionDisabled(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 5
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+	require.NoError(t, err)
+	require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+	require.NotEmpty(t, invokeRes.Script)
+
+	require.Len(t, invokeRes.Stack, 1)
+	stackItem := invokeRes.Stack[0]
+	require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+	iterVal, ok := stackItem.Value().(result.Iterator)
+	require.True(t, ok)
+
+	require.Equal(t, len(iterVal.Values), 5)
+	require.True(t, iterVal.Truncated)
+}
+
+func TestSessionExpansionExtension_E2E2(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 50
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: We need one more test for MPT-backed sessions.
created_at: 2025-03-10T10:46:48Z
author_association: MEMBER
diff_hunk: @@ -2523,3 +2523,105 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension_SessionDisabled(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 5
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+	require.NoError(t, err)
+	require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+	require.NotEmpty(t, invokeRes.Script)
+
+	require.Len(t, invokeRes.Stack, 1)
+	stackItem := invokeRes.Stack[0]
+	require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+	iterVal, ok := stackItem.Value().(result.Iterator)
+	require.True(t, ok)
+
+	require.Equal(t, len(iterVal.Values), 5)
+	require.True(t, iterVal.Truncated)
+}
+
+func TestSessionExpansionExtension_E2E2(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 50
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: Move this code to https://github.com/nspcc-dev/neo-go/blob/7d72b6538a72f99d214ec4e9a5f75ed4fe9a1ca2/pkg/services/rpcsrv/server_test.go#L138 and reuse from other places. Move this part to a separate commit.
created_at: 2025-03-10T10:49:08Z
author_association: MEMBER
diff_hunk: @@ -20,6 +20,7 @@ type (
 		MaxRequestHeaderBytes     int           `yaml:"MaxRequestHeaderBytes"`
 		MaxWebSocketClients       int           `yaml:"MaxWebSocketClients"`
 		SessionEnabled            bool          `yaml:"SessionEnabled"`
+		SessionExpansionEnabled   bool          `yaml:"SessionExpansionEnabled"`
path: pkg/config/rpc_config.go
user: login: AnnaShaleva
body: > Close https://github.com/nspcc-dev/neo-go/pull/3827

Close https://github.com/nspcc-dev/neo-go/issues/3818.
created_at: 2025-03-10T10:51:10Z
author_association: MEMBER
diff_hunk: @@ -119,6 +131,17 @@ func (r *Iterator) UnmarshalJSON(data []byte) error {
 		}
 		r.Truncated = iteratorAux.Truncated
 	}
+	if len(iteratorAux.Value) != 0 {
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Ditto, unify the code.
created_at: 2025-03-10T10:53:44Z
author_association: MEMBER
diff_hunk: @@ -89,6 +89,18 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 		iaux.Value = value
 		iaux.Truncated = r.Truncated
 	}
+	if len(r.Values) != 0 {
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: This code makes documentation of `result.Iterator` outdated.
created_at: 2025-03-10T10:56:23Z
author_association: MEMBER
diff_hunk: @@ -89,6 +89,18 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 		iaux.Value = value
 		iaux.Truncated = r.Truncated
 	}
+	if len(r.Values) != 0 {
+		value := make([]json.RawMessage, len(r.Values))
+		for i := range r.Values {
+			var err error
+			value[i], err = stackitem.ToJSONWithTypes(r.Values[i])
+			if err != nil {
+				return nil, err
+			}
+		}
+		iaux.Value = value
+		iaux.Truncated = r.Truncated
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: I don't see client-side changes. `invoker.TraverseIterator` won't work with this code. And I need a unit-test for `Invoker`'s iterator traversal for case when this extension is enabled.
created_at: 2025-03-10T10:58:26Z
author_association: MEMBER
diff_hunk: @@ -2523,3 +2523,105 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension_SessionDisabled(t *testing.T) {
+	chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, func(cfg *config.Config) {
+		cfg.ApplicationConfiguration.RPC.Enabled = true
+		cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+		cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+		cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = 5
+	})
+	t.Cleanup(rpcSrv.Shutdown)
+	t.Cleanup(httpSrv.Close)
+
+	for _, b := range getTestBlocks(t) {
+		require.NoError(t, chain.AddBlock(b))
+	}
+
+	c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+	require.NoError(t, err)
+	require.NoError(t, c.Init())
+	t.Cleanup(c.Close)
+
+	storageHash, err := util.Uint160DecodeStringLE(storageContractHash)
+	require.NoError(t, err)
+
+	invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+	require.NoError(t, err)
+	require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+	require.NotEmpty(t, invokeRes.Script)
+
+	require.Len(t, invokeRes.Stack, 1)
+	stackItem := invokeRes.Stack[0]
+	require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+	iterVal, ok := stackItem.Value().(result.Iterator)
+	require.True(t, ok)
+
+	require.Equal(t, len(iterVal.Values), 5)
+	require.True(t, iterVal.Truncated)
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: Check that sessionID is not set in the resulted iterator. Check iterator ID as far.
created_at: 2025-03-10T11:01:55Z
author_association: MEMBER
diff_hunk: @@ -89,6 +89,18 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 		iaux.Value = value
 		iaux.Truncated = r.Truncated
 	}
+	if len(r.Values) != 0 {
+		value := make([]json.RawMessage, len(r.Values))
+		for i := range r.Values {
+			var err error
+			value[i], err = stackitem.ToJSONWithTypes(r.Values[i])
+			if err != nil {
+				return nil, err
+			}
+		}
+		iaux.Value = value
+		iaux.Truncated = r.Truncated
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Check also `unwrap` package.
created_at: 2025-03-10T11:08:53Z
author_association: MEMBER
diff_hunk: @@ -27,6 +27,13 @@ type (
 	RPC struct {
 		MaxIteratorResultItems int  `json:"maxiteratorresultitems"`
 		SessionEnabled         bool `json:"sessionenabled"`
+
+		// Below are NeoGo-specific extensions to the rpc that are
+		// returned by the server in case they're enabled.
+
+		// SessionExpansionEnabled is true if the server supports
+		// iterator expansion for up to MaxIteratorResultItems.
+		SessionExpansionEnabled bool `json:"sessionexpansionenabled"`
path: pkg/neorpc/result/version.go
user: login: AnnaShaleva
body: I don't see this feature being well-documented in our docs. We need to update both `docs/node-comfiguration.md` and `docs/rpc.md`.
created_at: 2025-03-10T11:10:06Z
author_association: MEMBER
diff_hunk: @@ -20,6 +20,7 @@ type (
 		MaxRequestHeaderBytes     int           `yaml:"MaxRequestHeaderBytes"`
 		MaxWebSocketClients       int           `yaml:"MaxWebSocketClients"`
 		SessionEnabled            bool          `yaml:"SessionEnabled"`
+		SessionExpansionEnabled   bool          `yaml:"SessionExpansionExtension"`
path: pkg/config/rpc_config.go
user: login: AnnaShaleva
body: Not resolved.
created_at: 2025-03-10T11:10:35Z
author_association: MEMBER
diff_hunk: @@ -136,6 +136,9 @@ var (
 	// nfsoHash is a hash of NEP-11 divisible "examples/nft-d" ("NeoFS
 	// Object") contract deployed at block #17 of basic testing chain.
 	nfsoHash, _ = util.Uint160DecodeStringLE(nfsoContractHash)
+	// storageHash is a hash of "Storage" contract deployed at block #22 of
path: pkg/services/rpcsrv/server_test.go
user: login: AnnaShaleva
body: Specify contract src path and move it under `nnsHash` in order not to split `nfsoHash` and `nfsoToken...`
created_at: 2025-03-14T11:20:46Z
author_association: MEMBER
diff_hunk: @@ -314,6 +315,14 @@ where:
   requirement, then we recommend either to decrease `SessionExpirationTime` or to
   enable `SessionBackedByMPT`, see `SessionBackedByMPT` documentation for more
   details.
+- `SessionExpansionEnabled` enables partial expansion of iterators returned by 
+  `invoke*` calls. When enabled, the server returns up to `MaxIteratorResultItems`
+  values from the iterator in the initial response. If more items are available, 
+  the result is marked as truncated. When `SessionEnabled` is also true, the 
+  remaining iterator state is stored in a session, allowing further traversal 
+  via `traverseiterator`. If disabled, iterators are either fully expanded (if 
+  sessions are off) or require explicit traversal via session-based calls.
path: docs/node-configuration.md
user: login: AnnaShaleva
body: This documentation is good, I like it.

> fully expanded (if sessions are off)

But it's not exactly true, because they are expanded up to `MaxIteratorResultItems`.

created_at: 2025-03-14T12:46:47Z
author_association: MEMBER
diff_hunk: @@ -421,6 +421,18 @@ The invocation records are presented in a flat structure in the order as how the
 Note that invocation records for faulted transactions are kept and are present in the 
 applicationlog. This behaviour differs from notifications which are omitted for faulted transactions.
 
+#### SessionExpansionEnabled extension
path: docs/rpc.md
user: login: AnnaShaleva
body: Iterator sessions expansion
created_at: 2025-03-14T12:49:53Z
author_association: MEMBER
diff_hunk: @@ -421,6 +421,18 @@ The invocation records are presented in a flat structure in the order as how the
 Note that invocation records for faulted transactions are kept and are present in the 
 applicationlog. This behaviour differs from notifications which are omitted for faulted transactions.
 
+#### SessionExpansionEnabled extension
+
+`SessionExpansionEnabled` – When enabled, the RPC server returns only a partial expansion of any 
path: docs/rpc.md
user: login: AnnaShaleva
body: The `SessionExpansionEnabled` configuration setting enforces RPC server to expand any iterator...
created_at: 2025-03-14T12:53:35Z
author_association: MEMBER
diff_hunk: @@ -421,6 +421,18 @@ The invocation records are presented in a flat structure in the order as how the
 Note that invocation records for faulted transactions are kept and are present in the 
 applicationlog. This behaviour differs from notifications which are omitted for faulted transactions.
 
+#### SessionExpansionEnabled extension
+
+`SessionExpansionEnabled` – When enabled, the RPC server returns only a partial expansion of any 
+iterator returned by `invoke*` calls. Specifically, up to `MaxIteratorResultItems` elements are 
+returned in the initial response, and the returned iterator is marked as `Truncated` if the 
+iterator contains more elements beyond the returned batch. In this case, if `SessionEnabled`
+is also `true`, an iterator `ID` is provided allowing clients to retrieve additional elements via
path: docs/rpc.md
user: login: AnnaShaleva
body: > an iterator `ID`

and session ID
created_at: 2025-03-14T12:54:54Z
author_association: MEMBER
diff_hunk: @@ -421,6 +421,18 @@ The invocation records are presented in a flat structure in the order as how the
 Note that invocation records for faulted transactions are kept and are present in the 
 applicationlog. This behaviour differs from notifications which are omitted for faulted transactions.
 
+#### SessionExpansionEnabled extension
+
+`SessionExpansionEnabled` – When enabled, the RPC server returns only a partial expansion of any 
+iterator returned by `invoke*` calls. Specifically, up to `MaxIteratorResultItems` elements are 
+returned in the initial response, and the returned iterator is marked as `Truncated` if the 
+iterator contains more elements beyond the returned batch. In this case, if `SessionEnabled`
+is also `true`, an iterator `ID` is provided allowing clients to retrieve additional elements via
+`traverseiterator`. If SessionExpansionEnabled is false, the iterator behavior depends on 
+`SessionEnabled`: if `SessionEnabled` is `true`, a full iterator object with an ID is returned.
+If `SessionEnabled` is `false`, the iterator is expanded up to `MaxIteratorResultItems`, and 
+additional values cannot be retrieved.
path: docs/rpc.md
user: login: AnnaShaleva
body: remove ", and additional values cannot be retrieved." because they can, but likely via MPT-related calls.
created_at: 2025-03-14T12:56:47Z
author_association: MEMBER
diff_hunk: @@ -38,12 +38,20 @@ func IsIterator(item stackitem.Item) bool {
 
 // ValuesTruncated returns an array of up to `maxNum` iterator values. The second
 // return parameter denotes whether iterator is truncated, i.e. has more values.
-// The provided iterator CAN NOT be reused in the subsequent calls to Values and
-// to ValuesTruncated.
-func ValuesTruncated(item stackitem.Item, maxNum int) ([]stackitem.Item, bool) {
+// The third return parameter is the next value in the iterator. If the iterator
+// is not truncated, the third return parameter is nil. The provided iterator
+// CAN NOT be reused in the subsequent calls to Values and to ValuesTruncated.
path: pkg/core/interop/iterator/interop.go
user: login: AnnaShaleva
body: The case is that with this extension the iterator can be reused, but it's the user's duty to include `curr` to the resulting list. Add a note that `curr` won't be included into the list during subsequent traversal.
created_at: 2025-03-14T12:59:29Z
author_association: MEMBER
diff_hunk: @@ -55,11 +55,12 @@ type iteratorAux struct {
 	Truncated bool              `json:"truncated,omitempty"`
 }
 
-// Iterator represents VM iterator identifier. It either has ID set (for those JSON-RPC servers
-// that support sessions) or non-nil Values and Truncated set (for those JSON-RPC servers that
-// doesn't support sessions but perform in-place iterator traversing) or doesn't have ID, Values
-// and Truncated set at all (for those JSON-RPC servers that doesn't support iterator sessions
-// and doesn't perform in-place iterator traversing).
+// Iterator represents a VM iterator identifier. It can be in one of three
+// states: 1. If the JSON-RPC server supports session-based iterators, the ID
+// field is set. 2. If the JSON-RPC server does not support sessions but allows
+// in-place iteration, the Values field will be populated, and Truncated will
+// indicate whether more items exist. 3. If the JSON-RPC server neither supports
+// sessions nor in-place iteration, all fields will be unset.
 type Iterator struct {
 	// ID represents iterator ID. It is non-nil iff JSON-RPC server support session mechanism.
 	ID *uuid.UUID
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: The documentation below is outdated:

> It is non-nil iff JSON-RPC server does not support sessions mechanism and able to traverse iterator.

this `iff` is not true anymore, because now we can have non-nil `Values` not only when iterator support is disabled, and that's the key point.
created_at: 2025-03-14T13:26:11Z
author_association: MEMBER
diff_hunk: @@ -55,11 +55,12 @@ type iteratorAux struct {
 	Truncated bool              `json:"truncated,omitempty"`
 }
 
-// Iterator represents VM iterator identifier. It either has ID set (for those JSON-RPC servers
-// that support sessions) or non-nil Values and Truncated set (for those JSON-RPC servers that
-// doesn't support sessions but perform in-place iterator traversing) or doesn't have ID, Values
-// and Truncated set at all (for those JSON-RPC servers that doesn't support iterator sessions
-// and doesn't perform in-place iterator traversing).
+// Iterator represents a VM iterator identifier. It can be in one of three
+// states: 1. If the JSON-RPC server supports session-based iterators, the ID
+// field is set. 2. If the JSON-RPC server does not support sessions but allows
+// in-place iteration, the Values field will be populated, and Truncated will
+// indicate whether more items exist. 3. If the JSON-RPC server neither supports
+// sessions nor in-place iteration, all fields will be unset.
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Nothing is told about `Values` for the first case, whereas it's the key point of this PR.
created_at: 2025-03-14T13:27:11Z
author_association: MEMBER
diff_hunk: @@ -72,12 +73,11 @@ type Iterator struct {
 
 // MarshalJSON implements the json.Marshaler.
 func (r Iterator) MarshalJSON() ([]byte, error) {
-	var iaux iteratorAux
-	iaux.Type = stackitem.InteropT.String()
-	if r.ID != nil {
-		iaux.Interface = iteratorInterfaceName
-		iaux.ID = r.ID.String()
-	} else {
+	iaux := iteratorAux{
+		Type:      stackitem.InteropT.String(),
+		Truncated: r.Truncated,
+	}
+	if len(r.Values) > 0 {
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: We can't use this check because `r.Values == nil` is the key difference.
created_at: 2025-03-14T13:29:00Z
author_association: MEMBER
diff_hunk: @@ -87,37 +87,45 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 			}
 		}
 		iaux.Value = value
-		iaux.Truncated = r.Truncated
+	}
+	if r.ID != nil {
+		iaux.Interface = iteratorInterfaceName
+		iaux.ID = r.ID.String()
 	}
 	return json.Marshal(iaux)
 }
 
 // UnmarshalJSON implements the json.Unmarshaler.
 func (r *Iterator) UnmarshalJSON(data []byte) error {
-	iteratorAux := new(iteratorAux)
-	err := json.Unmarshal(data, iteratorAux)
-	if err != nil {
+	iaux := new(iteratorAux)
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Useless renaming, revert it.
created_at: 2025-03-14T13:30:14Z
author_association: MEMBER
diff_hunk: @@ -87,37 +87,45 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 			}
 		}
 		iaux.Value = value
-		iaux.Truncated = r.Truncated
+	}
+	if r.ID != nil {
+		iaux.Interface = iteratorInterfaceName
+		iaux.ID = r.ID.String()
 	}
 	return json.Marshal(iaux)
 }
 
 // UnmarshalJSON implements the json.Unmarshaler.
 func (r *Iterator) UnmarshalJSON(data []byte) error {
-	iteratorAux := new(iteratorAux)
-	err := json.Unmarshal(data, iteratorAux)
-	if err != nil {
+	iaux := new(iteratorAux)
+	if err := json.Unmarshal(data, &iaux); err != nil {
 		return err
 	}
-	if len(iteratorAux.Interface) != 0 {
-		if iteratorAux.Interface != iteratorInterfaceName {
-			return fmt.Errorf("unknown InteropInterface: %s", iteratorAux.Interface)
+	if len(iaux.Value) > 0 {
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: You need to use `nil` check instead.
created_at: 2025-03-14T13:30:29Z
author_association: MEMBER
diff_hunk: @@ -27,6 +27,13 @@ type (
 	RPC struct {
 		MaxIteratorResultItems int  `json:"maxiteratorresultitems"`
 		SessionEnabled         bool `json:"sessionenabled"`
+
+		// Below are NeoGo-specific extensions to the rpc that are
path: pkg/neorpc/result/version.go
user: login: AnnaShaleva
body: > rpc

JSON-RPC protocol
created_at: 2025-03-14T13:31:57Z
author_association: MEMBER
diff_hunk: @@ -27,6 +27,13 @@ type (
 	RPC struct {
 		MaxIteratorResultItems int  `json:"maxiteratorresultitems"`
 		SessionEnabled         bool `json:"sessionenabled"`
+
+		// Below are NeoGo-specific extensions to the rpc that are
+		// returned by the server in case they're enabled.
path: pkg/neorpc/result/version.go
user: login: AnnaShaleva
body: > returned by the server in case they're enabled.

And you need to provide that using `omitempty` JSON tag.
created_at: 2025-03-14T13:32:56Z
author_association: MEMBER
diff_hunk: @@ -27,6 +27,13 @@ type (
 	RPC struct {
 		MaxIteratorResultItems int  `json:"maxiteratorresultitems"`
 		SessionEnabled         bool `json:"sessionenabled"`
+
+		// Below are NeoGo-specific extensions to the rpc that are
+		// returned by the server in case they're enabled.
+
+		// SessionExpansionEnabled is true if the server supports
+		// iterator expansion for up to MaxIteratorResultItems.
path: pkg/neorpc/result/version.go
user: login: AnnaShaleva
body: Remove `for up to MaxIteratorResultItems.`.
created_at: 2025-03-14T13:33:40Z
author_association: MEMBER
diff_hunk: @@ -217,21 +217,32 @@ func termSession(rpc RPCSessions, sessionID uuid.UUID) error {
 // process. If num <= 0 then DefaultIteratorResultItems number of elements is
 // requested. If result contains no elements, then either Iterator has no
 // elements or session was expired and terminated by the server.
+// If server has SessionExpansionEnabled, then it's possible to have both
+// session-backed and expanded iterators. In this case, the iterator will
+// first serve the expanded items and only then request more from the server.
+// If the server doesn't support sessions, then the iterator will only contain
+// expanded items and will not make any additional requests to the server.
+// In this case, the iterator will be empty after the first call to TraverseIterator.
path: pkg/rpcclient/invoker/invoker.go
user: login: AnnaShaleva
body: I think we don't need to specify this behaviour since it's an upper-level API. The user is OK with `It works both with session-backed iterators and expanded ones`.
created_at: 2025-03-14T13:45:00Z
author_association: MEMBER
diff_hunk: @@ -217,21 +217,32 @@ func termSession(rpc RPCSessions, sessionID uuid.UUID) error {
 // process. If num <= 0 then DefaultIteratorResultItems number of elements is
 // requested. If result contains no elements, then either Iterator has no
 // elements or session was expired and terminated by the server.
+// If server has SessionExpansionEnabled, then it's possible to have both
+// session-backed and expanded iterators. In this case, the iterator will
+// first serve the expanded items and only then request more from the server.
+// If the server doesn't support sessions, then the iterator will only contain
+// expanded items and will not make any additional requests to the server.
+// In this case, the iterator will be empty after the first call to TraverseIterator.
 func (v *Invoker) TraverseIterator(sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) {
-	return iterateNext(v.client, sessionID, iterator, num)
-}
-
-func iterateNext(rpc RPCSessions, sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) {
path: pkg/rpcclient/invoker/invoker.go
user: login: AnnaShaleva
body: Why did you removed `iterateNext`?
created_at: 2025-03-14T13:45:14Z
author_association: MEMBER
diff_hunk: @@ -217,21 +217,32 @@ func termSession(rpc RPCSessions, sessionID uuid.UUID) error {
 // process. If num <= 0 then DefaultIteratorResultItems number of elements is
 // requested. If result contains no elements, then either Iterator has no
 // elements or session was expired and terminated by the server.
+// If server has SessionExpansionEnabled, then it's possible to have both
+// session-backed and expanded iterators. In this case, the iterator will
+// first serve the expanded items and only then request more from the server.
+// If the server doesn't support sessions, then the iterator will only contain
+// expanded items and will not make any additional requests to the server.
+// In this case, the iterator will be empty after the first call to TraverseIterator.
 func (v *Invoker) TraverseIterator(sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) {
-	return iterateNext(v.client, sessionID, iterator, num)
-}
-
-func iterateNext(rpc RPCSessions, sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) {
 	if num <= 0 {
 		num = DefaultIteratorResultItems
 	}
-
+	if len(iterator.Values) > 0 {
+		count := min(num, len(iterator.Values))
+		batch := iterator.Values[:count]
+		iterator.Values = iterator.Values[count:]
+		return batch, nil
+	}
 	if iterator.ID != nil {
-		return rpc.TraverseIterator(sessionID, *iterator.ID, num)
+		newBatch, err := v.client.TraverseIterator(sessionID, *iterator.ID, num)
path: pkg/rpcclient/invoker/invoker.go
user: login: AnnaShaleva
body: This code won't work if I request `num` that is less than `len(iterator.Values)`. In this case some values will be still left in the `iterator.Values` and some values will be left on the server side, so next request to `TraverseIterator` won't return the full set of values. I need a seamless transition from `iterator.Values` source of items to server-based session. So you need to fetch the head from `iterator.Values` firstly, and if more elements is requested you need to as them from session.
created_at: 2025-03-14T13:53:09Z
author_association: MEMBER
diff_hunk: @@ -217,21 +217,32 @@ func termSession(rpc RPCSessions, sessionID uuid.UUID) error {
 // process. If num <= 0 then DefaultIteratorResultItems number of elements is
 // requested. If result contains no elements, then either Iterator has no
 // elements or session was expired and terminated by the server.
+// If server has SessionExpansionEnabled, then it's possible to have both
+// session-backed and expanded iterators. In this case, the iterator will
+// first serve the expanded items and only then request more from the server.
+// If the server doesn't support sessions, then the iterator will only contain
+// expanded items and will not make any additional requests to the server.
+// In this case, the iterator will be empty after the first call to TraverseIterator.
 func (v *Invoker) TraverseIterator(sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) {
-	return iterateNext(v.client, sessionID, iterator, num)
-}
-
-func iterateNext(rpc RPCSessions, sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) {
 	if num <= 0 {
 		num = DefaultIteratorResultItems
 	}
-
+	if len(iterator.Values) > 0 {
+		count := min(num, len(iterator.Values))
+		batch := iterator.Values[:count]
+		iterator.Values = iterator.Values[count:]
+		return batch, nil
+	}
 	if iterator.ID != nil {
-		return rpc.TraverseIterator(sessionID, *iterator.ID, num)
+		newBatch, err := v.client.TraverseIterator(sessionID, *iterator.ID, num)
path: pkg/rpcclient/invoker/invoker.go
user: login: AnnaShaleva
body: I need an E2E unit-test for the described situation.
created_at: 2025-03-14T13:53:47Z
author_association: MEMBER
diff_hunk: @@ -235,7 +235,7 @@ func ArrayAndSessionIterator(r *result.Invoke, err error) ([]stackitem.Item, uui
 	if err != nil {
 		return nil, uuid.UUID{}, result.Iterator{}, err
 	}
-	if (r.Session == uuid.UUID{}) {
+	if (r.Session == uuid.UUID{}) && iter.ID != nil {
path: pkg/rpcclient/unwrap/unwrap.go
user: login: AnnaShaleva
body: Why did you add this change? This method shouldn't be changed because it serves to unwrap the result of `CreateCallAndPrefetchIteratorScript`, so nothing changed for this method.
created_at: 2025-03-14T13:57:16Z
author_association: MEMBER
diff_hunk: @@ -2518,3 +2518,159 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension(t *testing.T) {
+	const (
+		// storageItemsCount is the amount of storage items stored in Storage contract,
+		// it's hard-coded in the contract code.
+		storageItemsCount              = 255
+		expectedMaxIteratorResultItems = 50
+		batchSize                      = 33
+	)
+
+	expected := make([][]byte, storageItemsCount)
+	for i := range storageItemsCount {
+		expected[i] = stackitem.NewBigInteger(big.NewInt(int64(i))).Bytes()
+	}
+	slices.SortFunc(expected, func(a, b []byte) int {
+		if len(a) != len(b) {
+			return len(a) - len(b)
+		}
+		return bytes.Compare(a, b)
+	})
+	setup := func(t *testing.T, cfgMod func(*config.Config)) *rpcclient.Client {
+		chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, cfgMod)
+		t.Cleanup(rpcSrv.Shutdown)
+		t.Cleanup(httpSrv.Close)
+
+		for _, b := range getTestBlocks(t) {
+			require.NoError(t, chain.AddBlock(b))
+		}
+
+		c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+		require.NoError(t, err)
+		require.NoError(t, c.Init())
+		t.Cleanup(c.Close)
+
+		return c
+	}
+	prepareSession := func(t *testing.T, c *rpcclient.Client) (uuid.UUID, uuid.UUID, []stackitem.Item) {
+		res, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.NotEmpty(t, res.Session)
+		require.Equal(t, 1, len(res.Stack))
+		require.Equal(t, stackitem.InteropT, res.Stack[0].Type())
+		iterator, ok := res.Stack[0].Value().(result.Iterator)
+		require.True(t, ok)
+		require.NotEmpty(t, iterator.ID)
+		return res.Session, *iterator.ID, iterator.Values
+	}
+	t.Run("session disabled", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+		invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+		require.NotEmpty(t, invokeRes.Script)
+		require.Empty(t, invokeRes.Session)
+
+		require.Len(t, invokeRes.Stack, 1)
+		stackItem := invokeRes.Stack[0]
+		require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+		iterVal, ok := stackItem.Value().(result.Iterator)
+		require.True(t, ok)
+
+		require.Equal(t, expectedMaxIteratorResultItems, len(iterVal.Values))
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: I need the exact comparison of returned items, not just the length check. It's not so critical for session-disabled test, but it's critical for session-enabled and expansion-enabled test.
created_at: 2025-03-14T14:01:21Z
author_association: MEMBER
diff_hunk: @@ -2518,3 +2518,159 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension(t *testing.T) {
+	const (
+		// storageItemsCount is the amount of storage items stored in Storage contract,
+		// it's hard-coded in the contract code.
+		storageItemsCount              = 255
+		expectedMaxIteratorResultItems = 50
+		batchSize                      = 33
+	)
+
+	expected := make([][]byte, storageItemsCount)
+	for i := range storageItemsCount {
+		expected[i] = stackitem.NewBigInteger(big.NewInt(int64(i))).Bytes()
+	}
+	slices.SortFunc(expected, func(a, b []byte) int {
+		if len(a) != len(b) {
+			return len(a) - len(b)
+		}
+		return bytes.Compare(a, b)
+	})
+	setup := func(t *testing.T, cfgMod func(*config.Config)) *rpcclient.Client {
+		chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, cfgMod)
+		t.Cleanup(rpcSrv.Shutdown)
+		t.Cleanup(httpSrv.Close)
+
+		for _, b := range getTestBlocks(t) {
+			require.NoError(t, chain.AddBlock(b))
+		}
+
+		c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+		require.NoError(t, err)
+		require.NoError(t, c.Init())
+		t.Cleanup(c.Close)
+
+		return c
+	}
+	prepareSession := func(t *testing.T, c *rpcclient.Client) (uuid.UUID, uuid.UUID, []stackitem.Item) {
+		res, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.NotEmpty(t, res.Session)
+		require.Equal(t, 1, len(res.Stack))
+		require.Equal(t, stackitem.InteropT, res.Stack[0].Type())
+		iterator, ok := res.Stack[0].Value().(result.Iterator)
+		require.True(t, ok)
+		require.NotEmpty(t, iterator.ID)
+		return res.Session, *iterator.ID, iterator.Values
+	}
+	t.Run("session disabled", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+		invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+		require.NotEmpty(t, invokeRes.Script)
+		require.Empty(t, invokeRes.Session)
+
+		require.Len(t, invokeRes.Stack, 1)
+		stackItem := invokeRes.Stack[0]
+		require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+		iterVal, ok := stackItem.Value().(result.Iterator)
+		require.True(t, ok)
+
+		require.Equal(t, expectedMaxIteratorResultItems, len(iterVal.Values))
+		require.True(t, iterVal.Truncated)
+		require.Empty(t, iterVal.ID)
+	})
+	t.Run("session disabled and SessionBackedByMPT", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+			cfg.ApplicationConfiguration.RPC.SessionBackedByMPT = true
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: This test is useless because `SessionBackedByMPT` is valid only if `SessionEnabled`, remove it.
created_at: 2025-03-14T14:01:57Z
author_association: MEMBER
diff_hunk: @@ -2518,3 +2518,159 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension(t *testing.T) {
+	const (
+		// storageItemsCount is the amount of storage items stored in Storage contract,
+		// it's hard-coded in the contract code.
+		storageItemsCount              = 255
+		expectedMaxIteratorResultItems = 50
+		batchSize                      = 33
+	)
+
+	expected := make([][]byte, storageItemsCount)
+	for i := range storageItemsCount {
+		expected[i] = stackitem.NewBigInteger(big.NewInt(int64(i))).Bytes()
+	}
+	slices.SortFunc(expected, func(a, b []byte) int {
+		if len(a) != len(b) {
+			return len(a) - len(b)
+		}
+		return bytes.Compare(a, b)
+	})
+	setup := func(t *testing.T, cfgMod func(*config.Config)) *rpcclient.Client {
+		chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, cfgMod)
+		t.Cleanup(rpcSrv.Shutdown)
+		t.Cleanup(httpSrv.Close)
+
+		for _, b := range getTestBlocks(t) {
+			require.NoError(t, chain.AddBlock(b))
+		}
+
+		c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+		require.NoError(t, err)
+		require.NoError(t, c.Init())
+		t.Cleanup(c.Close)
+
+		return c
+	}
+	prepareSession := func(t *testing.T, c *rpcclient.Client) (uuid.UUID, uuid.UUID, []stackitem.Item) {
+		res, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.NotEmpty(t, res.Session)
+		require.Equal(t, 1, len(res.Stack))
+		require.Equal(t, stackitem.InteropT, res.Stack[0].Type())
+		iterator, ok := res.Stack[0].Value().(result.Iterator)
+		require.True(t, ok)
+		require.NotEmpty(t, iterator.ID)
+		return res.Session, *iterator.ID, iterator.Values
+	}
+	t.Run("session disabled", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+		invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+		require.NotEmpty(t, invokeRes.Script)
+		require.Empty(t, invokeRes.Session)
+
+		require.Len(t, invokeRes.Stack, 1)
+		stackItem := invokeRes.Stack[0]
+		require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+		iterVal, ok := stackItem.Value().(result.Iterator)
+		require.True(t, ok)
+
+		require.Equal(t, expectedMaxIteratorResultItems, len(iterVal.Values))
+		require.True(t, iterVal.Truncated)
+		require.Empty(t, iterVal.ID)
+	})
+	t.Run("session disabled and SessionBackedByMPT", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+			cfg.ApplicationConfiguration.RPC.SessionBackedByMPT = true
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+
+		invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+		require.NotEmpty(t, invokeRes.Script)
+		require.Empty(t, invokeRes.Session)
+
+		require.Len(t, invokeRes.Stack, 1)
+		stackItem := invokeRes.Stack[0]
+		require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+		iterVal, ok := stackItem.Value().(result.Iterator)
+		require.True(t, ok)
+
+		require.Equal(t, expectedMaxIteratorResultItems, len(iterVal.Values))
+		require.True(t, iterVal.Truncated)
+		require.Empty(t, iterVal.ID)
+	})
+	t.Run("session enabled", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+
+		sID, iID, curr := prepareSession(t, c)
+		require.Equal(t, len(curr), expectedMaxIteratorResultItems)
+		for i := range curr {
+			require.Equal(t, expected[i], curr[i].Value().([]byte), i)
+		}
+
+		var batchCount int
+		for {
+			batch, err := c.TraverseIterator(sID, iID, batchSize)
+			require.NoError(t, err)
+			if len(batch) == 0 {
+				break
+			}
+			for i := range batch {
+				require.Equal(t, expected[50+batchCount*batchSize+i], batch[i].Value().([]byte), i)
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: Replace 50 with constant, you already have it.
created_at: 2025-03-14T14:03:09Z
author_association: MEMBER
diff_hunk: @@ -2518,3 +2518,159 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension(t *testing.T) {
+	const (
+		// storageItemsCount is the amount of storage items stored in Storage contract,
+		// it's hard-coded in the contract code.
+		storageItemsCount              = 255
+		expectedMaxIteratorResultItems = 50
+		batchSize                      = 33
+	)
+
+	expected := make([][]byte, storageItemsCount)
+	for i := range storageItemsCount {
+		expected[i] = stackitem.NewBigInteger(big.NewInt(int64(i))).Bytes()
+	}
+	slices.SortFunc(expected, func(a, b []byte) int {
+		if len(a) != len(b) {
+			return len(a) - len(b)
+		}
+		return bytes.Compare(a, b)
+	})
+	setup := func(t *testing.T, cfgMod func(*config.Config)) *rpcclient.Client {
+		chain, rpcSrv, httpSrv := initClearServerWithCustomConfig(t, cfgMod)
+		t.Cleanup(rpcSrv.Shutdown)
+		t.Cleanup(httpSrv.Close)
+
+		for _, b := range getTestBlocks(t) {
+			require.NoError(t, chain.AddBlock(b))
+		}
+
+		c, err := rpcclient.New(context.Background(), httpSrv.URL, rpcclient.Options{})
+		require.NoError(t, err)
+		require.NoError(t, c.Init())
+		t.Cleanup(c.Close)
+
+		return c
+	}
+	prepareSession := func(t *testing.T, c *rpcclient.Client) (uuid.UUID, uuid.UUID, []stackitem.Item) {
+		res, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.NotEmpty(t, res.Session)
+		require.Equal(t, 1, len(res.Stack))
+		require.Equal(t, stackitem.InteropT, res.Stack[0].Type())
+		iterator, ok := res.Stack[0].Value().(result.Iterator)
+		require.True(t, ok)
+		require.NotEmpty(t, iterator.ID)
+		return res.Session, *iterator.ID, iterator.Values
+	}
+	t.Run("session disabled", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+		invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+		require.NotEmpty(t, invokeRes.Script)
+		require.Empty(t, invokeRes.Session)
+
+		require.Len(t, invokeRes.Stack, 1)
+		stackItem := invokeRes.Stack[0]
+		require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+		iterVal, ok := stackItem.Value().(result.Iterator)
+		require.True(t, ok)
+
+		require.Equal(t, expectedMaxIteratorResultItems, len(iterVal.Values))
+		require.True(t, iterVal.Truncated)
+		require.Empty(t, iterVal.ID)
+	})
+	t.Run("session disabled and SessionBackedByMPT", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = false
+			cfg.ApplicationConfiguration.RPC.SessionBackedByMPT = true
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+
+		invokeRes, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil)
+		require.NoError(t, err)
+		require.Equal(t, vmstate.Halt.String(), invokeRes.State)
+		require.NotEmpty(t, invokeRes.Script)
+		require.Empty(t, invokeRes.Session)
+
+		require.Len(t, invokeRes.Stack, 1)
+		stackItem := invokeRes.Stack[0]
+		require.Equal(t, stackitem.InteropT, stackItem.Type())
+
+		iterVal, ok := stackItem.Value().(result.Iterator)
+		require.True(t, ok)
+
+		require.Equal(t, expectedMaxIteratorResultItems, len(iterVal.Values))
+		require.True(t, iterVal.Truncated)
+		require.Empty(t, iterVal.ID)
+	})
+	t.Run("session enabled", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
+			cfg.ApplicationConfiguration.RPC.MaxIteratorResultItems = expectedMaxIteratorResultItems
+		})
+
+		sID, iID, curr := prepareSession(t, c)
+		require.Equal(t, len(curr), expectedMaxIteratorResultItems)
+		for i := range curr {
+			require.Equal(t, expected[i], curr[i].Value().([]byte), i)
+		}
+
+		var batchCount int
+		for {
+			batch, err := c.TraverseIterator(sID, iID, batchSize)
+			require.NoError(t, err)
+			if len(batch) == 0 {
+				break
+			}
+			for i := range batch {
+				require.Equal(t, expected[50+batchCount*batchSize+i], batch[i].Value().([]byte), i)
+			}
+			batchCount++
+		}
+		require.Equal(t, (storageItemsCount-expectedMaxIteratorResultItems)/batchSize+1, batchCount)
+	})
+	t.Run("session enabled and SessionBackedByMPT", func(t *testing.T) {
+		c := setup(t, func(cfg *config.Config) {
+			cfg.ApplicationConfiguration.RPC.Enabled = true
+			cfg.ApplicationConfiguration.RPC.SessionEnabled = true
+			cfg.ApplicationConfiguration.RPC.SessionBackedByMPT = true
+			cfg.ApplicationConfiguration.RPC.SessionExpansionEnabled = true
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: I need one more test here: `SessionEnabled = false` and `SessionExpansionEnabled=false` in order to ensure that the old behaviour is the same.
created_at: 2025-03-14T14:06:32Z
author_association: MEMBER
diff_hunk: @@ -2518,3 +2518,159 @@ func TestGetBlockNotifications(t *testing.T) {
 		require.NotEmpty(t, bn)
 	})
 }
+
+func TestSessionExpansionExtension(t *testing.T) {
path: pkg/services/rpcsrv/client_test.go
user: login: AnnaShaleva
body: It's `TestClient_SessionExpasionExtension`.
created_at: 2025-03-14T14:07:16Z
author_association: MEMBER
diff_hunk: @@ -185,6 +185,9 @@ type (
 		ID string
 		// Item represents Iterator stackitem.
 		Item stackitem.Item
+		// Curr represents current Iterator value, used for in-place iterator traversal. The size of Curr is
+		// limited by `config.MaxIteratorResultItems`.
path: pkg/services/rpcsrv/server.go
user: login: AnnaShaleva
body: What does `size of Curr` mean? `Curr` is a single element, it's not an array.
created_at: 2025-03-14T14:08:01Z
author_association: MEMBER
diff_hunk: @@ -185,6 +185,9 @@ type (
 		ID string
 		// Item represents Iterator stackitem.
 		Item stackitem.Item
+		// Curr represents current Iterator value, used for in-place iterator traversal. The size of Curr is
path: pkg/services/rpcsrv/server.go
user: login: AnnaShaleva
body: ...current Iterator value got after initial iterator traversal if in-place iterator traversal is performed. This value must be used to prepend to the resulting items list during the subsequent iterator traversal.
created_at: 2025-03-14T14:11:47Z
author_association: MEMBER
diff_hunk: @@ -2505,22 +2513,33 @@ func (s *Server) postProcessExecStack(stack []stackitem.Item) *session {
 
 // registerOrDumpIterator changes iterator interop stack items into result.Iterator
 // interop stack items and returns a uuid for it if sessions are enabled. All the other stack
-// items are not changed.
-func (s *Server) registerOrDumpIterator(item stackitem.Item) (stackitem.Item, uuid.UUID) {
+// items are not changed. The third return value is the current iterator value if it's not nil.
+func (s *Server) registerOrDumpIterator(item stackitem.Item) (stackitem.Item, uuid.UUID, stackitem.Item) {
 	var iterID uuid.UUID
 
 	if (item.Type() != stackitem.InteropT) || !iterator.IsIterator(item) {
-		return item, iterID
+		return item, iterID, nil
 	}
-	var resIterator result.Iterator
+	var (
+		resIterator result.Iterator
+		curr        stackitem.Item
+	)
 
-	if s.config.SessionEnabled {
-		iterID = uuid.New()
-		resIterator.ID = &iterID
+	if s.config.SessionExpansionEnabled {
path: pkg/services/rpcsrv/server.go
user: login: AnnaShaleva
body: Add verification to the config level that `SessionExpansionEnabled` may be enabled only if `SessionEnabled` is enabled.
created_at: 2025-03-14T14:14:15Z
author_association: MEMBER
diff_hunk: @@ -2505,22 +2513,33 @@ func (s *Server) postProcessExecStack(stack []stackitem.Item) *session {
 
 // registerOrDumpIterator changes iterator interop stack items into result.Iterator
 // interop stack items and returns a uuid for it if sessions are enabled. All the other stack
-// items are not changed.
-func (s *Server) registerOrDumpIterator(item stackitem.Item) (stackitem.Item, uuid.UUID) {
+// items are not changed. The third return value is the current iterator value if it's not nil.
+func (s *Server) registerOrDumpIterator(item stackitem.Item) (stackitem.Item, uuid.UUID, stackitem.Item) {
 	var iterID uuid.UUID
 
 	if (item.Type() != stackitem.InteropT) || !iterator.IsIterator(item) {
-		return item, iterID
+		return item, iterID, nil
 	}
-	var resIterator result.Iterator
+	var (
+		resIterator result.Iterator
+		curr        stackitem.Item
+	)
 
-	if s.config.SessionEnabled {
-		iterID = uuid.New()
-		resIterator.ID = &iterID
+	if s.config.SessionExpansionEnabled {
+		resIterator.Values, resIterator.Truncated, curr = iterator.ValuesTruncated(item, s.config.MaxIteratorResultItems)
+		if resIterator.Truncated && s.config.SessionEnabled {
+			iterID = uuid.New()
+			resIterator.ID = &iterID
+		}
 	} else {
-		resIterator.Values, resIterator.Truncated = iterator.ValuesTruncated(item, s.config.MaxIteratorResultItems)
+		if s.config.SessionEnabled {
+			iterID = uuid.New()
+			resIterator.ID = &iterID
+		} else {
+			resIterator.Values, resIterator.Truncated, curr = iterator.ValuesTruncated(item, s.config.MaxIteratorResultItems)
+		}
path: pkg/services/rpcsrv/server.go
user: login: AnnaShaleva
body: There's still code duplication.
created_at: 2025-03-14T14:15:40Z
author_association: MEMBER
diff_hunk: @@ -217,21 +217,32 @@ func termSession(rpc RPCSessions, sessionID uuid.UUID) error {
 // process. If num <= 0 then DefaultIteratorResultItems number of elements is
 // requested. If result contains no elements, then either Iterator has no
 // elements or session was expired and terminated by the server.
+// If server has SessionExpansionEnabled, then it's possible to have both
+// session-backed and expanded iterators. In this case, the iterator will
+// first serve the expanded items and only then request more from the server.
+// If the server doesn't support sessions, then the iterator will only contain
+// expanded items and will not make any additional requests to the server.
+// In this case, the iterator will be empty after the first call to TraverseIterator.
 func (v *Invoker) TraverseIterator(sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) {
-	return iterateNext(v.client, sessionID, iterator, num)
-}
-
-func iterateNext(rpc RPCSessions, sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) {
 	if num <= 0 {
 		num = DefaultIteratorResultItems
 	}
-
+	if len(iterator.Values) > 0 {
+		count := min(num, len(iterator.Values))
+		batch := iterator.Values[:count]
+		iterator.Values = iterator.Values[count:]
+		return batch, nil
+	}
 	if iterator.ID != nil {
-		return rpc.TraverseIterator(sessionID, *iterator.ID, num)
+		newBatch, err := v.client.TraverseIterator(sessionID, *iterator.ID, num)
path: pkg/rpcclient/invoker/invoker.go
user: login: AliceInHunterland
body: tried to implement it in `TestClient_SessionExpasionExtension`. is it okay?
created_at: 2025-03-17T07:15:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,3 +38,14 @@ type (
 		KeyFile      string `yaml:"KeyFile"`
 	}
 )
+
+// Validate checks RPC for internal consistency. It returns an error if the
+// configuration is invalid.
+func (cfg *RPC) Validate() error {
+	if cfg.SessionExpansionEnabled {
+		if !cfg.SessionEnabled {
path: pkg/config/rpc_config.go
user: login: AnnaShaleva
body: Unify under a single `if` statement.
created_at: 2025-03-17T10:18:23Z
author_association: MEMBER
diff_hunk: @@ -38,12 +38,21 @@ func IsIterator(item stackitem.Item) bool {
 
 // ValuesTruncated returns an array of up to `maxNum` iterator values. The second
 // return parameter denotes whether iterator is truncated, i.e. has more values.
-// The provided iterator CAN NOT be reused in the subsequent calls to Values and
-// to ValuesTruncated.
-func ValuesTruncated(item stackitem.Item, maxNum int) ([]stackitem.Item, bool) {
+// The third return parameter is the next value in the iterator. If the iterator
+// is not truncated, the third return parameter is nil. The iterator can be reused
path: pkg/core/interop/iterator/interop.go
user: login: AnnaShaleva
body: > If the iterator is not truncated, the third return parameter is nil.

If iterator doesn't have more values, then third return parameter is nil.

created_at: 2025-03-17T10:20:37Z
author_association: MEMBER
diff_hunk: @@ -55,29 +55,32 @@ type iteratorAux struct {
 	Truncated bool              `json:"truncated,omitempty"`
 }
 
-// Iterator represents VM iterator identifier. It either has ID set (for those JSON-RPC servers
-// that support sessions) or non-nil Values and Truncated set (for those JSON-RPC servers that
-// doesn't support sessions but perform in-place iterator traversing) or doesn't have ID, Values
-// and Truncated set at all (for those JSON-RPC servers that doesn't support iterator sessions
-// and doesn't perform in-place iterator traversing).
+// Iterator represents a VM iterator identifier. It can be in one of three
+// states: 1. If the JSON-RPC server supports session-based iterators, the
+// ID field is set, and the Values field may contain a partial expansion of
+// the iterator if session expansion is enabled. 2. If the JSON-RPC server
+// does not support sessions but allows in-place iteration, the Values field
+// will be populated, and Truncated will indicate whether more items exist.
+// 3. If the JSON-RPC server neither supports sessions nor in-place iteration,
+// all fields will be unset.
 type Iterator struct {
 	// ID represents iterator ID. It is non-nil iff JSON-RPC server support session mechanism.
 	ID *uuid.UUID
 
-	// Values contains deserialized VM iterator values with a truncated flag. It is non-nil
-	// iff JSON-RPC server does not support sessions mechanism and able to traverse iterator.
+	// Values contains deserialized VM iterator values with a truncated flag. It may be non-nil
+	// if session expansion is enabled (even when ID is set),or if the JSON-RPC server does not
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Missing space.
created_at: 2025-03-17T10:22:17Z
author_association: MEMBER
diff_hunk: @@ -87,37 +90,45 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 			}
 		}
 		iaux.Value = value
-		iaux.Truncated = r.Truncated
+	}
+	if r.ID != nil {
+		iaux.Interface = iteratorInterfaceName
+		iaux.ID = r.ID.String()
 	}
 	return json.Marshal(iaux)
 }
 
 // UnmarshalJSON implements the json.Unmarshaler.
 func (r *Iterator) UnmarshalJSON(data []byte) error {
 	iteratorAux := new(iteratorAux)
-	err := json.Unmarshal(data, iteratorAux)
-	if err != nil {
+	if err := json.Unmarshal(data, iteratorAux); err != nil {
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Useless change, revert it.
created_at: 2025-03-17T10:27:16Z
author_association: MEMBER
diff_hunk: @@ -87,37 +90,45 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 			}
 		}
 		iaux.Value = value
-		iaux.Truncated = r.Truncated
+	}
+	if r.ID != nil {
+		iaux.Interface = iteratorInterfaceName
+		iaux.ID = r.ID.String()
 	}
 	return json.Marshal(iaux)
 }
 
 // UnmarshalJSON implements the json.Unmarshaler.
 func (r *Iterator) UnmarshalJSON(data []byte) error {
 	iteratorAux := new(iteratorAux)
-	err := json.Unmarshal(data, iteratorAux)
-	if err != nil {
+	if err := json.Unmarshal(data, iteratorAux); err != nil {
 		return err
 	}
+	if iteratorAux.Value != nil {
+		items := make([]stackitem.Item, len(iteratorAux.Value))
+		for i := range iteratorAux.Value {
+			var err error
+			items[i], err = stackitem.FromJSONWithTypes(iteratorAux.Value[i])
+			if err != nil {
+				return fmt.Errorf("failed to unmarshal iterator values: %w", err)
+			}
+		}
+		r.Values = items
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Don't need to declare additional `items`. Just move the old code under `if iteratorAux.Value != nil` condition, that's it.
created_at: 2025-03-17T10:34:09Z
author_association: MEMBER
diff_hunk: @@ -87,37 +90,45 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 			}
 		}
 		iaux.Value = value
-		iaux.Truncated = r.Truncated
+	}
+	if r.ID != nil {
+		iaux.Interface = iteratorInterfaceName
+		iaux.ID = r.ID.String()
 	}
 	return json.Marshal(iaux)
 }
 
 // UnmarshalJSON implements the json.Unmarshaler.
 func (r *Iterator) UnmarshalJSON(data []byte) error {
 	iteratorAux := new(iteratorAux)
-	err := json.Unmarshal(data, iteratorAux)
-	if err != nil {
+	if err := json.Unmarshal(data, iteratorAux); err != nil {
 		return err
 	}
+	if iteratorAux.Value != nil {
+		items := make([]stackitem.Item, len(iteratorAux.Value))
+		for i := range iteratorAux.Value {
+			var err error
+			items[i], err = stackitem.FromJSONWithTypes(iteratorAux.Value[i])
+			if err != nil {
+				return fmt.Errorf("failed to unmarshal iterator values: %w", err)
+			}
+		}
+		r.Values = items
+	}
+	r.Truncated = iteratorAux.Truncated
+
 	if len(iteratorAux.Interface) != 0 {
 		if iteratorAux.Interface != iteratorInterfaceName {
 			return fmt.Errorf("unknown InteropInterface: %s", iteratorAux.Interface)
 		}
-		var iID uuid.UUID
-		iID, err = uuid.Parse(iteratorAux.ID)
-		if err != nil {
-			return fmt.Errorf("failed to unmarshal iterator ID: %w", err)
-		}
-		r.ID = &iID
-	} else {
-		r.Values = make([]stackitem.Item, len(iteratorAux.Value))
-		for j := range r.Values {
-			r.Values[j], err = stackitem.FromJSONWithTypes(iteratorAux.Value[j])
+
+		if iteratorAux.ID != "" {
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Remove this check. If interop interface is iterator then ID must be present.
created_at: 2025-03-17T10:35:26Z
author_association: MEMBER
diff_hunk: @@ -87,37 +90,45 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 			}
 		}
 		iaux.Value = value
-		iaux.Truncated = r.Truncated
+	}
+	if r.ID != nil {
+		iaux.Interface = iteratorInterfaceName
+		iaux.ID = r.ID.String()
 	}
 	return json.Marshal(iaux)
 }
 
 // UnmarshalJSON implements the json.Unmarshaler.
 func (r *Iterator) UnmarshalJSON(data []byte) error {
 	iteratorAux := new(iteratorAux)
-	err := json.Unmarshal(data, iteratorAux)
-	if err != nil {
+	if err := json.Unmarshal(data, iteratorAux); err != nil {
 		return err
 	}
+	if iteratorAux.Value != nil {
+		items := make([]stackitem.Item, len(iteratorAux.Value))
+		for i := range iteratorAux.Value {
+			var err error
+			items[i], err = stackitem.FromJSONWithTypes(iteratorAux.Value[i])
+			if err != nil {
+				return fmt.Errorf("failed to unmarshal iterator values: %w", err)
+			}
+		}
+		r.Values = items
+	}
+	r.Truncated = iteratorAux.Truncated
+
 	if len(iteratorAux.Interface) != 0 {
 		if iteratorAux.Interface != iteratorInterfaceName {
 			return fmt.Errorf("unknown InteropInterface: %s", iteratorAux.Interface)
 		}
-		var iID uuid.UUID
-		iID, err = uuid.Parse(iteratorAux.ID)
-		if err != nil {
-			return fmt.Errorf("failed to unmarshal iterator ID: %w", err)
-		}
-		r.ID = &iID
-	} else {
-		r.Values = make([]stackitem.Item, len(iteratorAux.Value))
-		for j := range r.Values {
-			r.Values[j], err = stackitem.FromJSONWithTypes(iteratorAux.Value[j])
+
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Useless line.
created_at: 2025-03-17T10:36:00Z
author_association: MEMBER
diff_hunk: @@ -226,12 +228,22 @@ func iterateNext(rpc RPCSessions, sessionID uuid.UUID, iterator *result.Iterator
 		num = DefaultIteratorResultItems
 	}
 
-	if iterator.ID != nil {
-		return rpc.TraverseIterator(sessionID, *iterator.ID, num)
+	var result []stackitem.Item
+
+	if iterator != nil && len(iterator.Values) > 0 {
path: pkg/rpcclient/invoker/invoker.go
user: login: AnnaShaleva
body: Iterator can't be nil here, remove this check.
created_at: 2025-03-17T10:46:22Z
author_association: MEMBER
diff_hunk: @@ -226,12 +228,22 @@ func iterateNext(rpc RPCSessions, sessionID uuid.UUID, iterator *result.Iterator
 		num = DefaultIteratorResultItems
 	}
 
-	if iterator.ID != nil {
-		return rpc.TraverseIterator(sessionID, *iterator.ID, num)
+	var result []stackitem.Item
+
+	if iterator != nil && len(iterator.Values) > 0 {
+		count := min(num, len(iterator.Values))
+		result = append(result, iterator.Values[:count]...)
+		iterator.Values = iterator.Values[count:]
+		num -= count
+	}
+
+	if num > 0 && iterator != nil && iterator.ID != nil {
path: pkg/rpcclient/invoker/invoker.go
user: login: AnnaShaleva
body: Ditto, remove `iterator != nil` check.
created_at: 2025-03-17T10:46:40Z
author_association: MEMBER
diff_hunk: @@ -51,7 +51,16 @@ func (r *rpcInv) TerminateSession(sessionID uuid.UUID) (bool, error) {
 	return r.resTrm, r.err
 }
 func (r *rpcInv) TraverseIterator(sessionID, iteratorID uuid.UUID, maxItemsCount int) ([]stackitem.Item, error) {
-	return r.resItm, r.err
+	if r.err != nil {
+		return nil, r.err
+	}
+	if maxItemsCount <= 0 || maxItemsCount > len(r.resItm) {
+		maxItemsCount = len(r.resItm)
path: pkg/rpcclient/invoker/invoker_test.go
user: login: AnnaShaleva
body: Use `min` instead of this `if`.
created_at: 2025-03-17T10:51:55Z
author_association: MEMBER
diff_hunk: @@ -2565,8 +2581,19 @@ func (s *Server) traverseIterator(reqParams params.Params) (any, *neorpc.Error)
 	)
 	for _, it := range session.iteratorIdentifiers {
 		if iIDStr == it.ID {
-			iVals = iterator.Values(it.Item, count)
 			found = true
+
+			if it.Curr != nil {
+				iVals = append(iVals, it.Curr)
+				it.Curr = nil
+				count--
path: pkg/services/rpcsrv/server.go
user: login: AnnaShaleva
body: This code won't work correctly if `count` is 0, add a unit-test for that and ensure.
created_at: 2025-03-17T10:56:50Z
author_association: MEMBER
diff_hunk: @@ -2565,8 +2581,19 @@ func (s *Server) traverseIterator(reqParams params.Params) (any, *neorpc.Error)
 	)
 	for _, it := range session.iteratorIdentifiers {
 		if iIDStr == it.ID {
-			iVals = iterator.Values(it.Item, count)
 			found = true
+
+			if it.Curr != nil {
+				iVals = append(iVals, it.Curr)
+				it.Curr = nil
+				count--
+				if count <= 0 {
+					break
+				}
+			}
+
+			sub := iterator.Values(it.Item, count)
+			iVals = append(iVals, sub...)
path: pkg/services/rpcsrv/server.go
user: login: AnnaShaleva
body: ```
iVals = append(iVals, iterator.Values(it.Item, count)...)
```
created_at: 2025-03-17T10:59:02Z
author_association: MEMBER
diff_hunk: @@ -99,25 +105,25 @@ func (r *Iterator) UnmarshalJSON(data []byte) error {
 	if err != nil {
 		return err
 	}
+	if iteratorAux.Value != nil {
+		r.Values = make([]stackitem.Item, len(iteratorAux.Value))
+		for j := range r.Values {
+			r.Values[j], err = stackitem.FromJSONWithTypes(iteratorAux.Value[j])
+			if err != nil {
+				return fmt.Errorf("failed to unmarshal iterator values: %w", err)
+			}
+		}
+	}
+	r.Truncated = iteratorAux.Truncated
 	if len(iteratorAux.Interface) != 0 {
 		if iteratorAux.Interface != iteratorInterfaceName {
 			return fmt.Errorf("unknown InteropInterface: %s", iteratorAux.Interface)
 		}
-		var iID uuid.UUID
-		iID, err = uuid.Parse(iteratorAux.ID)
+		iID, err := uuid.Parse(iteratorAux.ID)
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Useless change, revert it. I'm asking for this because I appreciate preserving git history (`git blame` will tell you that this line was changed in this PR although this change is not expected to happen during session expansion development).
created_at: 2025-03-18T08:39:43Z
author_association: MEMBER
diff_hunk: @@ -99,25 +105,25 @@ func (r *Iterator) UnmarshalJSON(data []byte) error {
 	if err != nil {
 		return err
 	}
+	if iteratorAux.Value != nil {
+		r.Values = make([]stackitem.Item, len(iteratorAux.Value))
+		for j := range r.Values {
+			r.Values[j], err = stackitem.FromJSONWithTypes(iteratorAux.Value[j])
+			if err != nil {
+				return fmt.Errorf("failed to unmarshal iterator values: %w", err)
+			}
+		}
+	}
+	r.Truncated = iteratorAux.Truncated
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: And then if you move this code below and place it exactly after `if len(iteratorAux.Interface) != 0` block (as it was before, but with `if iteratorAux.Value != nil` check), then the inner code block (`r.Values = make([]stackitem.Item,...`) won't be marked as "changed" by `git diff`, and that's exactly what I'm looking for when I ask not to perform useless changes.

The only thing that will be affected is, in fact, the `} else {` line at L112 and the `r.Truncated = iteratorAux.Truncated` condition that should be moved out of this block.
created_at: 2025-03-18T08:43:18Z
author_association: MEMBER
diff_hunk: @@ -87,7 +90,10 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 			}
 		}
 		iaux.Value = value
-		iaux.Truncated = r.Truncated
+	}
+	if r.ID != nil {
+		iaux.Interface = iteratorInterfaceName
+		iaux.ID = r.ID.String()
path: pkg/neorpc/result/invoke.go
user: login: AnnaShaleva
body: Ditto (https://github.com/nspcc-dev/neo-go/pull/3827#discussion_r2000483930) for this part, take a look at git diff, the problem is the same.
created_at: 2025-03-18T10:31:27Z
author_association: MEMBER
diff_hunk: @@ -87,7 +90,10 @@ func (r Iterator) MarshalJSON() ([]byte, error) {
 			}
 		}
 		iaux.Value = value
-		iaux.Truncated = r.Truncated
+	}
+	if r.ID != nil {
+		iaux.Interface = iteratorInterfaceName
+		iaux.ID = r.ID.String()
path: pkg/neorpc/result/invoke.go
user: login: AliceInHunterland
body: Sorry!!
created_at: 2025-03-18T11:23:08Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-03-05T01:30:33Z
body: ## [Codecov](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3827?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) Report
Attention: Patch coverage is `84.61538%` with `10 lines` in your changes missing coverage. Please review.
> Project coverage is 82.44%. Comparing base [(`24a6d84`)](https://app.codecov.io/gh/nspcc-dev/neo-go/commit/24a6d842b517b534ca778ca11d31fe8a885069fe?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) to head [(`20f88d4`)](https://app.codecov.io/gh/nspcc-dev/neo-go/commit/20f88d4103d3391377fe6997aaf38e26ab9db920?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev).
> Report is 5 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3827?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) | Patch % | Lines |
|---|---|---|
| [pkg/core/interop/iterator/interop.go](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3827?src=pr&el=tree&filepath=pkg%2Fcore%2Finterop%2Fiterator%2Finterop.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev#diff-cGtnL2NvcmUvaW50ZXJvcC9pdGVyYXRvci9pbnRlcm9wLmdv) | 60.00% | [3 Missing and 1 partial :warning: ](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3827?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) |
| [pkg/config/application\_config.go](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3827?src=pr&el=tree&filepath=pkg%2Fconfig%2Fapplication_config.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev#diff-cGtnL2NvbmZpZy9hcHBsaWNhdGlvbl9jb25maWcuZ28=) | 0.00% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3827?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) |
| [pkg/config/rpc\_config.go](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3827?src=pr&el=tree&filepath=pkg%2Fconfig%2Frpc_config.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev#diff-cGtnL2NvbmZpZy9ycGNfY29uZmlnLmdv) | 40.00% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3827?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #3827      +/-   ##
==========================================
- Coverage   82.45%   82.44%   -0.02%     
==========================================
  Files         340      341       +1     
  Lines       47700    47740      +40     
==========================================
+ Hits        39333    39358      +25     
- Misses       6737     6746       +9     
- Partials     1630     1636       +6     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nspcc-dev/neo-go/pull/3827?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nspcc-dev).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: AnnaShaleva
created_at: 2025-03-10T11:11:23Z
body: Linter is failing.
author_association: MEMBER