Repository: 0xSpaceShard/starknet-devnet (language: python)

Issue:
`continuation_token` appears in response body after no events are left in `get_events` RPC function
**Describe the bug (observed vs expected behavior)**

The `get_events` method in RPC doesn't match the specification. Parameter `continuation_token` appears in response body after no events searched for are left. 

After deploying a cairo contract with code:
```
%lang starknet
%builtins pedersen range_check

from starkware.cairo.common.cairo_builtins import HashBuiltin

@storage_var
func storage(key: felt) -> (value: felt) {
}

@event
func put_called(key: felt, prev_value: felt, value: felt) {
}

@external
func put{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}(key: felt, value: felt) {
    let (prev_value) = storage.read(key);
    put_called.emit(key=key, prev_value=prev_value, value=value);
    storage.write(key, value);
    return ();
}
```
invoking `put` function 2 times and calling `get_events` in RPC with `"chunk_size": 4` the response looks like this:
```
{
    "id": 0,
    "jsonrpc": "2.0",
    "result": {
        "continuation_token": "1",
        "events": [
            {
                "block_hash": "0x06b0679b3060f2f1d02ec484f238f78a0a7c5aeb6bb0ced56edd40e8ddff05ff",
                "block_number": 3,
                "data": [
                    "0x01",
                    "0x00",
                    "0x01"
                ],
                "from_address": "0x021895a10d1f7319919b58967ec629d9fe6dc53269b0e24b8127ff4613759e55",
                "keys": [
                    "0x037e1449a2e98fcc818fa74e8abd5a3ebfefa48645be9b3777bd03e2c4ac54c0"
                ],
                "transaction_hash": "0x02304a74032d87b27f24537ae10105e29e014efb0fa9d0a9610132f490ac9373"
            },
            {
                "block_hash": "0x036f9108fb0b957df4d03eb18e7323c76c56219db854932a073d956ddba99347",
                "block_number": 4,
                "data": [
                    "0x02",
                    "0x00",
                    "0x02"
                ],
                "from_address": "0x021895a10d1f7319919b58967ec629d9fe6dc53269b0e24b8127ff4613759e55",
                "keys": [
                    "0x037e1449a2e98fcc818fa74e8abd5a3ebfefa48645be9b3777bd03e2c4ac54c0"
                ],
                "transaction_hash": "0x03037ea7104a49b4af996e0e3b21f886f1cd53c0742c8511d5f6ed2f553bec73"
            }
        ]
    }
}
```
Then, after adding `"continuation_token": "1"` to request body, the response looks like this:
```
{
    "id": 0,
    "jsonrpc": "2.0",
    "result": {
        "continuation_token": "1",
        "events": []
    }
}
```
I believe the problem lies [here](https://github.com/Shard-Labs/starknet-devnet/blob/v0.5.0a1/starknet_devnet/blueprints/rpc/misc.py#L155) and because [RpcEventsResult must return `continuation_token`, it isn't an optional parameter](https://github.com/Shard-Labs/starknet-devnet/blob/v0.5.0a1/starknet_devnet/blueprints/rpc/structures/responses.py#L79).

-----------------------------

**Not reproducible on alpha-goerli**

- [x] This issue is only present on Devnet and cannot be reproduced on alpha-goerli (check the box if true).

**To Reproduce**
Steps to reproduce the behavior:

1. Deploy any contract on devnet with function that emits an event.
2. Call the function that emits an event, e.g. 2 times.
3. Call `get_events` function in RPC and follow the `continuation_token` parameter. 
4. `continuation_token` appears in response body even though it shouldn't.

**Devnet version**

- I am using Devnet version: 0.5.0a1
- [ ] This happens with a dockerized Devnet (check the box if true).


Discussion / hints:
Thanks for the well-formulated issue! @mikiw I think the user has a point, can you take a look tomorrow?
Thanks for reporting. I'll fix that.

Fix patch:
diff --git a/starknet_devnet/blueprints/rpc/misc.py b/starknet_devnet/blueprints/rpc/misc.py
index d7c16419f..3ebd50308 100644
--- a/starknet_devnet/blueprints/rpc/misc.py
+++ b/starknet_devnet/blueprints/rpc/misc.py
@@ -17,6 +17,7 @@
 from starknet_devnet.blueprints.rpc.structures.responses import (
     EmittedEvent,
     RpcEventsResult,
+    RpcEventsResultWithoutContinuationToken,
 )
 from starknet_devnet.blueprints.rpc.structures.types import (
     Address,
@@ -112,7 +113,9 @@ async def _get_events_range(
 # pylint: disable=redefined-builtin
 # filter name is determined by current RPC implementation and starknet specification
 @validate_schema("getEvents")
-async def get_events(filter) -> RpcEventsResult:
+async def get_events(
+    filter,
+) -> Union[RpcEventsResult, RpcEventsResultWithoutContinuationToken]:
     """
     Returns all events matching the given filters.
 
@@ -149,13 +152,17 @@ async def get_events(filter) -> RpcEventsResult:
 
     # Chunking
     start_index = continuation_token * chunk_size
-    events = events[start_index : start_index + chunk_size]
+    chunked_events = events[start_index : start_index + chunk_size]
+    remaining_events_length = len(events) - start_index
 
     # Continuation_token should be increased only if events are not empty
-    if events:
+    if remaining_events_length > chunk_size:
         continuation_token = continuation_token + 1
+        return RpcEventsResult(
+            events=chunked_events, continuation_token=str(continuation_token)
+        )
 
-    return RpcEventsResult(events=events, continuation_token=str(continuation_token))
+    return RpcEventsResultWithoutContinuationToken(events=chunked_events)
 
 
 @validate_schema("getNonce")
diff --git a/starknet_devnet/blueprints/rpc/structures/responses.py b/starknet_devnet/blueprints/rpc/structures/responses.py
index 4fb8b1022..78bdf27c0 100644
--- a/starknet_devnet/blueprints/rpc/structures/responses.py
+++ b/starknet_devnet/blueprints/rpc/structures/responses.py
@@ -85,6 +85,14 @@ class RpcEventsResult(TypedDict):
     continuation_token: str
 
 
+class RpcEventsResultWithoutContinuationToken(TypedDict):
+    """
+    TypedDict for rpc get events result without continuation token
+    """
+
+    events: List[EmittedEvent]
+
+
 class RpcBaseTransactionReceipt(TypedDict):
     """TypedDict for rpc transaction receipt"""
 


Test patch:
diff --git a/test/rpc/test_data/get_events.py b/test/rpc/test_data/get_events.py
index a3c0b8149..eaf415b49 100644
--- a/test/rpc/test_data/get_events.py
+++ b/test/rpc/test_data/get_events.py
@@ -8,120 +8,72 @@
     PREDEPLOY_ACCOUNT_CLI_ARGS,
     PREDEPLOYED_ACCOUNT_ADDRESS,
 )
+from typing import List, Optional, Union
 
-from starknet_devnet.blueprints.rpc.utils import rpc_felt
-
-BLOCK_FROM_0_TO_LATEST = {
-    "from_block": {"block_number": 0},
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-        rpc_felt(INCREASE_BALANCE_CALLED_EVENT_KEY),
-    ],
-    "to_block": "latest",
-    "chunk_size": 10,
-}
-
-BLOCK_FROM_0x0_TO_LATEST = {
-    "from_block": {"block_hash": rpc_felt("0x0")},
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "to_block": "latest",
-    "chunk_size": 10,
-}
-
-BLOCK_FROM_0_TO_LATEST_MALFORMED_REQUEST = {
-    "from_block": {"block_number": 0},
-    "to_block": "latest",
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "chunk_size": "test",
-}
+from starkware.starknet.services.api.feeder_gateway.response_objects import (
+    BlockIdentifier,
+    BlockNumber,
+    LatestBlock,
+    PendingBlock,
+)
 
-BLOCK_FROM_0_TO_LATEST_MISSING_PARAMETER = {
-    "from_block": {"block_number": 0},
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "to_block": "latest",
-    "chunk_size": 10,
-}
+from starknet_devnet.blueprints.rpc.structures.types import BlockHash
+from starknet_devnet.blueprints.rpc.utils import rpc_felt
 
-BLOCK_FROM_0_TO_LATEST_WRONG_BLOCK_TYPE = {
-    "from_block": {"block_number": "0x0"},
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "to_block": "latest",
-    "chunk_size": 10,
-}
 
-BLOCK_FROM_0_TO_2 = {
-    "from_block": {"block_hash": rpc_felt("0x0")},
-    "to_block": {"block_number": 2},
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "chunk_size": 10,
-}
+def create_get_events_filter(
+    *,
+    from_block: Union[int, str] = 0,
+    to_block: Union[int, str] = "latest",
+    address: str = rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
+    keys: Optional[List[str]] = None,
+    chunk_size: int = 10,
+    continuation_token: Optional[str] = None
+) -> dict:
+    """
+    Method that creates ``filter`` parameter for `get_events` RPC call.
+
+    :param from_block: Identifier of the block (number of hash) from which the events start.
+        The value can be a rpc felt, an int or literals "latest" or "pending". Defaults to 0.
+    :param to_block: Identifier of the block (number or hash) to which the events end.
+        The value can be a rpc felt, an int or literals "latest" or "pending". Defaults to "latest".
+    :param address: Address of the contract.
+    :param keys: Names of events that are searched for in rpc felt form.
+    :param chunk_size: Size of returned one chunk of events, defaults to 10.
+    :param continuation_token: (optional) String with a continuation token.
+
+    :return: `filter` param matching the specification.
+    """
+    if keys is None:
+        keys = [rpc_felt(FEE_CHARGED_EVENT_KEY)]
+    filter_body = {
+        "from_block": parse_block_delimiter_parameter(from_block),
+        "address": address,
+        "keys": keys,
+        "to_block": parse_block_delimiter_parameter(to_block),
+        "chunk_size": chunk_size,
+    }
+    if continuation_token is not None:
+        filter_body["continuation_token"] = continuation_token
+    return {"filter": filter_body}
 
-BLOCK_FROM_3_TO_3 = {
-    "from_block": {"block_number": 3},
-    "to_block": {"block_number": 3},
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "chunk_size": 10,
-}
 
-BLOCK_FROM_2_TO_3 = {
-    "from_block": {"block_number": 2},
-    "to_block": {"block_number": 3},
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "chunk_size": 10,
-}
+def parse_block_delimiter_parameter(
+    block_delimiter: Union[BlockIdentifier, BlockHash]
+) -> Union[dict, LatestBlock, PendingBlock]:
+    """
+    Parses `from_block` and `to_block` parameters for ``create_get_events_filter`` function.
 
-BLOCK_FROM_0_TO_LATEST_CHUNK_SIZE_1 = {
-    "from_block": {"block_number": 0},
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "to_block": "latest",
-    "chunk_size": 1,
-}
+    :param block_delimiter: `block_hash`, `block_number` or literals "pending" or "latest".
 
-BLOCK_FROM_0_TO_LATEST_CHUNK_1_CONTINUATION_TOKEN = {
-    "from_block": {"block_number": 0},
-    "to_block": "latest",
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "chunk_size": 1,
-    "continuation_token": "1",
-}
+    :return: Dictionary matching the specification.
+    """
+    if block_delimiter in ("latest", "pending"):
+        return block_delimiter
+    if isinstance(block_delimiter, BlockNumber):
+        return {"block_number": block_delimiter}
+    return {"block_hash": block_delimiter}
 
-BLOCK_FROM_0_TO_1_CHUNK_3_CONTINUATION_TOKEN = {
-    "from_block": {"block_number": 0},
-    "to_block": {"block_number": 1},
-    "address": rpc_felt(EXPECTED_FEE_TOKEN_ADDRESS),
-    "keys": [
-        rpc_felt(FEE_CHARGED_EVENT_KEY),
-    ],
-    "chunk_size": 3,
-    "continuation_token": "0",
-}
 
 EVENT_FEE_ADDRESS = rpc_felt(
     0xBEBE7DEC64B911AEFFECC184AFCEFA6470E3B3652A1605E42D643E1EA9093D
@@ -140,22 +92,15 @@
 ]
 
 
-def create_get_events_rpc(filter_data):
-    """
-    Construct JSON RPC call with filter data
-    """
-    return {
-        "jsonrpc": "2.0",
-        "method": "starknet_getEvents",
-        "params": {"filter": filter_data},
-        "id": 0,
-    }
-
-
 GET_EVENTS_TEST_DATA = [
     (
         [*PREDEPLOY_ACCOUNT_CLI_ARGS],
-        create_get_events_rpc(BLOCK_FROM_0_TO_LATEST),
+        create_get_events_filter(
+            keys=[
+                rpc_felt(FEE_CHARGED_EVENT_KEY),
+                rpc_felt(INCREASE_BALANCE_CALLED_EVENT_KEY),
+            ]
+        ),
         [
             FEE_CHARGING_IN_BLOCK_2_EVENT,
             FEE_CHARGING_IN_BLOCK_3_EVENT,
@@ -163,7 +108,7 @@ def create_get_events_rpc(filter_data):
     ),
     (
         [*PREDEPLOY_ACCOUNT_CLI_ARGS],
-        create_get_events_rpc(BLOCK_FROM_0x0_TO_LATEST),
+        create_get_events_filter(from_block=rpc_felt("0x0")),
         [
             FEE_CHARGING_IN_BLOCK_2_EVENT,
             FEE_CHARGING_IN_BLOCK_3_EVENT,
@@ -171,37 +116,20 @@ def create_get_events_rpc(filter_data):
     ),
     (
         [*PREDEPLOY_ACCOUNT_CLI_ARGS],
-        create_get_events_rpc(BLOCK_FROM_0_TO_2),
+        create_get_events_filter(from_block=rpc_felt("0x0"), to_block=2),
         [FEE_CHARGING_IN_BLOCK_2_EVENT],
     ),
     (
         [*PREDEPLOY_ACCOUNT_CLI_ARGS],
-        create_get_events_rpc(BLOCK_FROM_3_TO_3),
+        create_get_events_filter(from_block=3, to_block=3),
         [FEE_CHARGING_IN_BLOCK_3_EVENT],
     ),
     (
         [*PREDEPLOY_ACCOUNT_CLI_ARGS],
-        create_get_events_rpc(BLOCK_FROM_2_TO_3),
+        create_get_events_filter(from_block=2, to_block=3),
         [
             FEE_CHARGING_IN_BLOCK_2_EVENT,
             FEE_CHARGING_IN_BLOCK_3_EVENT,
         ],
     ),
-    (
-        [*PREDEPLOY_ACCOUNT_CLI_ARGS],
-        create_get_events_rpc(BLOCK_FROM_0_TO_LATEST_CHUNK_SIZE_1),
-        [
-            FEE_CHARGING_IN_BLOCK_2_EVENT,
-        ],
-    ),
-    (
-        [*PREDEPLOY_ACCOUNT_CLI_ARGS],
-        create_get_events_rpc(BLOCK_FROM_0_TO_LATEST_CHUNK_1_CONTINUATION_TOKEN),
-        [FEE_CHARGING_IN_BLOCK_3_EVENT],
-    ),
-    (
-        [*PREDEPLOY_ACCOUNT_CLI_ARGS],
-        create_get_events_rpc(BLOCK_FROM_0_TO_1_CHUNK_3_CONTINUATION_TOKEN),
-        [],
-    ),
 ]
diff --git a/test/rpc/test_rpc_misc.py b/test/rpc/test_rpc_misc.py
index c99697c13..4c07e165d 100644
--- a/test/rpc/test_rpc_misc.py
+++ b/test/rpc/test_rpc_misc.py
@@ -6,25 +6,26 @@
 
 from test.account import declare, invoke
 from test.rpc.rpc_utils import deploy_and_invoke_storage_contract, rpc_call
-from test.rpc.test_data.get_events import (
-    BLOCK_FROM_0_TO_LATEST_MALFORMED_REQUEST,
-    BLOCK_FROM_0_TO_LATEST_MISSING_PARAMETER,
-    BLOCK_FROM_0_TO_LATEST_WRONG_BLOCK_TYPE,
-    GET_EVENTS_TEST_DATA,
-    create_get_events_rpc,
-)
+from test.rpc.test_data.get_events import GET_EVENTS_TEST_DATA, create_get_events_filter
 from test.shared import (
     CONTRACT_PATH,
     DEPLOYER_CONTRACT_PATH,
     EVENTS_CONTRACT_PATH,
     EXPECTED_CLASS_HASH,
     EXPECTED_FEE_TOKEN_ADDRESS,
+    PREDEPLOY_ACCOUNT_CLI_ARGS,
     PREDEPLOYED_ACCOUNT_ADDRESS,
     PREDEPLOYED_ACCOUNT_PRIVATE_KEY,
 )
 from test.test_account import deploy_empty_contract
 from test.test_state_update import get_class_hash_at_path
-from test.util import assert_hex_equal, assert_transaction, deploy
+from test.util import (
+    assert_get_events_response,
+    assert_hex_equal,
+    assert_transaction,
+    deploy,
+    devnet_in_background,
+)
 
 import pytest
 from starkware.starknet.public.abi import get_storage_var_address
@@ -165,11 +166,11 @@ def test_get_events_malformed_request():
     """
     Test RPC get_events with malformed request.
     """
+    params = create_get_events_filter()
+    params["filter"]["chunk_size"] = "test"
     resp = rpc_call(
         "starknet_getEvents",
-        params=create_get_events_rpc(BLOCK_FROM_0_TO_LATEST_MALFORMED_REQUEST)[
-            "params"
-        ],
+        params=params,
     )
     assert resp["error"]["code"] == PredefinedRpcErrorCode.INVALID_PARAMS.value
 
@@ -179,12 +180,9 @@ def test_get_events_missing_parameter():
     """
     Test RPC get_events with malformed request.
     """
-    resp = rpc_call(
-        "starknet_getEvents",
-        params=create_get_events_rpc(BLOCK_FROM_0_TO_LATEST_MISSING_PARAMETER)[
-            "params"
-        ],
-    )
+    params = create_get_events_filter()
+    del params["filter"]["address"]
+    resp = rpc_call("starknet_getEvents", params=params)
     assert resp["error"]["code"] == PredefinedRpcErrorCode.INVALID_PARAMS.value
 
 
@@ -193,13 +191,61 @@ def test_get_events_wrong_blockid_type():
     """
     Test RPC get_events with malformed request.
     """
+    params = create_get_events_filter()
+    params["filter"]["from_block"] = {"block_number": "0x0"}
     resp = rpc_call(
         "starknet_getEvents",
-        params=create_get_events_rpc(BLOCK_FROM_0_TO_LATEST_WRONG_BLOCK_TYPE)["params"],
+        params=params,
     )
     assert resp["error"]["code"] == PredefinedRpcErrorCode.INVALID_PARAMS.value
 
 
+@devnet_in_background(*PREDEPLOY_ACCOUNT_CLI_ARGS)
+def test_get_events_continuation_token():
+    """
+    Test RPC get_events returning continuation token.
+    """
+    deploy_info = deploy(EVENTS_CONTRACT_PATH)
+    total_invokes = 3
+    for i in range(total_invokes):
+        invoke(
+            calls=[(deploy_info["address"], "increase_balance", [i])],
+            account_address=PREDEPLOYED_ACCOUNT_ADDRESS,
+            private_key=PREDEPLOYED_ACCOUNT_PRIVATE_KEY,
+        )
+    resp = rpc_call(
+        "starknet_getEvents",
+        params=create_get_events_filter(chunk_size=total_invokes),
+    )
+    assert_get_events_response(resp, expected_block_length=total_invokes)
+
+    resp = rpc_call(
+        "starknet_getEvents",
+        params=create_get_events_filter(chunk_size=1),
+    )
+    assert_get_events_response(resp, expected_block_length=1, expected_token="1")
+
+    resp = rpc_call(
+        "starknet_getEvents",
+        params=create_get_events_filter(chunk_size=1, continuation_token="1"),
+    )
+    assert_get_events_response(resp, expected_block_length=1, expected_token="2")
+
+    resp = rpc_call(
+        "starknet_getEvents",
+        params=create_get_events_filter(chunk_size=1, continuation_token="2"),
+    )
+    assert_get_events_response(resp, expected_block_length=1)
+
+    resp = rpc_call(
+        "starknet_getEvents",
+        params=create_get_events_filter(
+            from_block=0, to_block=1, chunk_size=3, continuation_token="0"
+        ),
+    )
+    assert_get_events_response(resp, expected_block_length=0)
+
+
 @pytest.mark.usefixtures("run_devnet_in_background")
 @pytest.mark.parametrize(
     "run_devnet_in_background, input_data, expected_data",
@@ -217,21 +263,11 @@ def test_get_events(input_data, expected_data):
             account_address=PREDEPLOYED_ACCOUNT_ADDRESS,
             private_key=PREDEPLOYED_ACCOUNT_PRIVATE_KEY,
         )
-    resp = rpc_call("starknet_getEvents", params=input_data["params"])
+    resp = rpc_call("starknet_getEvents", params=input_data)
     assert len(expected_data) == len(resp["result"]["events"])
     for i, data in enumerate(expected_data):
         assert resp["result"]["events"][i]["data"] == data
 
-    if "continuation_token" in input_data["params"]["filter"]:
-        expected_continuation_token = int(
-            input_data["params"]["filter"]["continuation_token"]
-        )
-        # increase continuation_token when events are not empty
-        if resp["result"]["events"]:
-            expected_continuation_token += 1
-
-        assert expected_continuation_token == int(resp["result"]["continuation_token"])
-
 
 @pytest.mark.usefixtures("devnet_with_account")
 def test_get_nonce():
diff --git a/test/util.py b/test/util.py
index a8c87f6e2..df93c17e2 100644
--- a/test/util.py
+++ b/test/util.py
@@ -8,7 +8,7 @@
 import re
 import subprocess
 import time
-from typing import IO, List
+from typing import IO, List, Optional
 
 import pytest
 import requests
@@ -132,6 +132,20 @@ def assert_hex_equal(actual, expected):
     assert int(actual, 16) == int(expected, 16)
 
 
+def assert_get_events_response(
+    resp: dict, expected_block_length: int, expected_token: Optional[str] = None
+):
+    """
+    If expected_token is None, check that it is not present, if it's not None, check if the returned value matches
+    the one provided.
+    """
+    assert len(resp["result"]["events"]) == expected_block_length
+    if expected_token is not None:
+        assert resp["result"]["continuation_token"] == expected_token
+    else:
+        assert "continuation_token" not in resp["result"]
+
+
 def extract(regex, stdout):
     """Extract from `stdout` what matches `regex`."""
     matched = re.search(regex, stdout)