Repository: StellarCN/py-stellar-base
Pull request #1032: fix: make `start_ledger` optional and mutually exclusive with `cursor`
Author: login: aolieman
Created: 2025-04-10T09:21:37Z | Merged: 2025-04-11T14:00:50Z

Fix for #1031 
The `start_ledger` argument needs to be optional on RPC methods that support pagination. I've added model validation that enforces the mutual exclusivity with the `cursor` argument. This helps the SDK user see the error faster than if they'd get it from the RPC response.


Diff:
diff --git a/.gitignore b/.gitignore
index 799b90cf..593e12d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -105,3 +105,4 @@ venv.bak/
 
 # IDE
 .idea/
+.vscode/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 999073d9..aa504ba1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ Release History
 
 #### Update
 - fix: fix the issue where sending assets using `Transaction.append_payment_to_contract_op` fails when the sender's account is the same as the asset issuer's account. ([#1029](https://github.com/StellarCN/py-stellar-base/pull/1029))
+- fix: allow `SorobanServer.get_events()`, `.get_transactions()`, and `.get_ledgers()` to be paginated by making the `start_ledger` argument optional. ([#1032](https://github.com/StellarCN/py-stellar-base/pull/1032))
 
 ### Version 12.2.0
 
diff --git a/poetry.lock b/poetry.lock
index 6b7ee27a..41934f92 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
 
 [[package]]
 name = "aiohappyeyeballs"
@@ -1389,16 +1389,15 @@ files = [
 
 [[package]]
 name = "typing-extensions"
-version = "4.12.2"
+version = "4.13.2"
 description = "Backported and Experimental Type Hints for Python 3.8+"
 optional = false
 python-versions = ">=3.8"
 groups = ["main", "dev"]
 files = [
-    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
-    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+    {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"},
+    {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"},
 ]
-markers = {dev = "python_version < \"3.11\""}
 
 [[package]]
 name = "urllib3"
@@ -1569,4 +1568,4 @@ shamir = ["shamir-mnemonic"]
 [metadata]
 lock-version = "2.1"
 python-versions = ">=3.8,<4.0"
-content-hash = "a42ba5d091634b0d9e5236e50fa4d4ab15c59c51416f8ba1c2baeab8bf4cac87"
+content-hash = "23e576ab1cb88aac04bbb41e91c59071ef3855edcd82bb12ad62db0d0cecb8fa"
diff --git a/pyproject.toml b/pyproject.toml
index 46f42b12..bbf0df58 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,6 +58,7 @@ toml = "^0.10.2"
 pydantic = "^2.5.2"
 xdrlib3 = "^0.1.1"
 requests-sse = ">=0.3,<0.6"
+typing-extensions = "^4.13.2"
 
 [tool.poetry.extras]
 aiohttp = ["aiohttp", "aiohttp-sse-client"]
diff --git a/stellar_sdk/soroban_rpc.py b/stellar_sdk/soroban_rpc.py
index 676d0054..e345cecf 100644
--- a/stellar_sdk/soroban_rpc.py
+++ b/stellar_sdk/soroban_rpc.py
@@ -2,7 +2,8 @@
 from enum import Enum
 from typing import Any, Dict, Generic, List, Optional, Sequence, TypeVar, Union
 
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+from typing_extensions import Self
 
 T = TypeVar("T")
 
@@ -75,16 +76,27 @@ class PaginationOptions(BaseModel):
     limit: Optional[int] = None
 
 
-class GetEventsRequest(BaseModel):
+class PaginationMixin:
+    pagination: Optional[PaginationOptions] = None
+
+    @model_validator(mode="after")
+    def verify_ledger_or_cursor(self) -> Self:
+        if self.pagination and (
+            getattr(self, "start_ledger") and self.pagination.cursor
+        ):
+            raise ValueError("start_ledger and cursor cannot both be set")
+        return self
+
+
+class GetEventsRequest(PaginationMixin, BaseModel):
     """Response for JSON-RPC method getEvents.
 
     See `getEvents documentation <https://developers.stellar.org/docs/data/rpc/api-reference/methods/getEvents>`__ for
     more information.
     """
 
-    start_ledger: int = Field(alias="startLedger")
+    start_ledger: Optional[int] = Field(alias="startLedger", default=None)
     filters: Optional[Sequence[EventFilter]] = None
-    pagination: Optional[PaginationOptions] = None
 
 
 class GetEventsResponse(BaseModel):
@@ -376,14 +388,13 @@ class GetFeeStatsResponse(BaseModel):
 
 
 # get_transactions
-class GetTransactionsRequest(BaseModel):
+class GetTransactionsRequest(PaginationMixin, BaseModel):
     """Request for JSON-RPC method getTransactions.
 
     See `getTransactions documentation <https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions>`__ for
     more information."""
 
-    start_ledger: int = Field(alias="startLedger")
-    pagination: Optional[PaginationOptions] = None
+    start_ledger: Optional[int] = Field(alias="startLedger", default=None)
 
 
 class Transaction(BaseModel):
@@ -430,14 +441,13 @@ class GetVersionInfoResponse(BaseModel):
 
 
 # get_ledgers
-class GetLedgersRequest(BaseModel):
+class GetLedgersRequest(PaginationMixin, BaseModel):
     """Request for JSON-RPC method getLedgers.
 
     See `getLedgers documentation <https://developers.stellar.org/docs/data/rpc/api-reference/methods/getLedgers>`__ for
     more information."""
 
-    start_ledger: int = Field(alias="startLedger")
-    pagination: Optional[PaginationOptions] = None
+    start_ledger: Optional[int] = Field(alias="startLedger", default=None)
 
 
 class LedgerInfo(BaseModel):
diff --git a/stellar_sdk/soroban_server.py b/stellar_sdk/soroban_server.py
index 7ff38d9d..53edb682 100644
--- a/stellar_sdk/soroban_server.py
+++ b/stellar_sdk/soroban_server.py
@@ -67,7 +67,7 @@ def get_health(self) -> GetHealthResponse:
 
     def get_events(
         self,
-        start_ledger: int,
+        start_ledger: int = None,
         filters: Sequence[EventFilter] = None,
         cursor: str = None,
         limit: int = None,
@@ -85,7 +85,7 @@ def get_events(
         """
         pagination = PaginationOptions(cursor=cursor, limit=limit)
         data = GetEventsRequest(
-            startLedger=str(start_ledger),
+            startLedger=start_ledger,
             filters=filters,
             pagination=pagination,
         )
@@ -243,7 +243,7 @@ def get_fee_stats(self) -> GetFeeStatsResponse:
 
     def get_transactions(
         self,
-        start_ledger: int,
+        start_ledger: int = None,
         cursor: str = None,
         limit: int = None,
     ) -> GetTransactionsResponse:
@@ -260,7 +260,7 @@ def get_transactions(
         """
         pagination = PaginationOptions(cursor=cursor, limit=limit)
         data = GetTransactionsRequest(
-            startLedger=str(start_ledger),
+            startLedger=start_ledger,
             pagination=pagination,
         )
         request: Request = Request[GetTransactionsRequest](
@@ -269,7 +269,10 @@ def get_transactions(
         return self._post(request, GetTransactionsResponse)
 
     def get_ledgers(
-        self, start_ledger: int, cursor: str = None, limit: int = None
+        self,
+        start_ledger: int = None,
+        cursor: str = None,
+        limit: int = None,
     ) -> GetLedgersResponse:
         """Fetch a detailed list of ledgers starting from the user specified starting point that you can paginate
         as long as the pages fall within the history retention of their corresponding RPC provider.
@@ -284,7 +287,7 @@ def get_ledgers(
         """
         pagination = PaginationOptions(cursor=cursor, limit=limit)
         data = GetLedgersRequest(
-            startLedger=str(start_ledger),
+            startLedger=start_ledger,
             pagination=pagination,
         )
         request: Request = Request[GetLedgersRequest](
diff --git a/stellar_sdk/soroban_server_async.py b/stellar_sdk/soroban_server_async.py
index d4f05326..5a009110 100644
--- a/stellar_sdk/soroban_server_async.py
+++ b/stellar_sdk/soroban_server_async.py
@@ -67,7 +67,7 @@ async def get_health(self) -> GetHealthResponse:
 
     async def get_events(
         self,
-        start_ledger: int,
+        start_ledger: int = None,
         filters: Sequence[EventFilter] = None,
         cursor: str = None,
         limit: int = None,
@@ -85,7 +85,7 @@ async def get_events(
         """
         pagination = PaginationOptions(cursor=cursor, limit=limit)
         data = GetEventsRequest(
-            startLedger=str(start_ledger),
+            startLedger=start_ledger,
             filters=filters,
             pagination=pagination,
         )
@@ -242,7 +242,7 @@ async def get_fee_stats(self) -> GetFeeStatsResponse:
 
     async def get_transactions(
         self,
-        start_ledger: int,
+        start_ledger: int = None,
         cursor: str = None,
         limit: int = None,
     ) -> GetTransactionsResponse:
@@ -259,7 +259,7 @@ async def get_transactions(
         """
         pagination = PaginationOptions(cursor=cursor, limit=limit)
         data = GetTransactionsRequest(
-            startLedger=str(start_ledger),
+            startLedger=start_ledger,
             pagination=pagination,
         )
         request: Request = Request[GetTransactionsRequest](
@@ -268,7 +268,10 @@ async def get_transactions(
         return await self._post(request, GetTransactionsResponse)
 
     async def get_ledgers(
-        self, start_ledger: int, cursor: str = None, limit: int = None
+        self,
+        start_ledger: int = None,
+        cursor: str = None,
+        limit: int = None,
     ) -> GetLedgersResponse:
         """Fetch a detailed list of ledgers starting from the user specified starting point that you can paginate
         as long as the pages fall within the history retention of their corresponding RPC provider.
@@ -283,7 +286,7 @@ async def get_ledgers(
         """
         pagination = PaginationOptions(cursor=cursor, limit=limit)
         data = GetLedgersRequest(
-            startLedger=str(start_ledger),
+            startLedger=start_ledger,
             pagination=pagination,
         )
         request: Request = Request[GetLedgersRequest](
diff --git a/tests/test_soroban_server_async.py b/tests/test_soroban_server_async.py
index ed41ea53..49fc0280 100644
--- a/tests/test_soroban_server_async.py
+++ b/tests/test_soroban_server_async.py
@@ -2,6 +2,7 @@
 
 import pytest
 from aioresponses import aioresponses
+from pydantic import ValidationError
 from yarl import URL
 
 from stellar_sdk import Account, Keypair, Network, TransactionBuilder, scval
@@ -16,7 +17,7 @@
 from stellar_sdk.soroban_rpc import *
 from stellar_sdk.soroban_server_async import SorobanServerAsync
 
-PRC_URL = "https://example.com/soroban_rpc"
+RPC_URL = "https://example.com/soroban_rpc"
 
 
 @pytest.mark.asyncio
@@ -41,12 +42,12 @@ async def test_load_account(self):
         account_id = "GDAT5HWTGIU4TSSZ4752OUC4SABDLTLZFRPZUJ3D6LKBNEPA7V2CIG54"
 
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (await client.load_account(account_id)) == Account(
                     account_id, 3418793967628
                 )
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getLedgerEntries"
@@ -63,15 +64,15 @@ async def test_load_account_not_found_raise(self):
         }
         account_id = "GDAT5HWTGIU4TSSZ4752OUC4SABDLTLZFRPZUJ3D6LKBNEPA7V2CIG54"
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
+            m.post(RPC_URL, payload=data)
             with pytest.raises(
                 AccountNotFoundException,
                 match=f"Account not found, account_id: {account_id}",
             ):
-                async with SorobanServerAsync(PRC_URL) as client:
+                async with SorobanServerAsync(RPC_URL) as client:
                     await client.load_account(account_id)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getLedgerEntries"
@@ -92,12 +93,12 @@ async def test_get_health(self):
             "result": result,
         }
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert await client.get_health() == GetHealthResponse.model_validate(
                     result
                 )
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getHealth"
@@ -118,13 +119,13 @@ async def test_get_network(self):
         GetNetworkResponse.model_validate(result)
 
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert await client.get_network() == GetNetworkResponse.model_validate(
                     result
                 )
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getNetwork"
@@ -147,14 +148,14 @@ async def test_version_info(self):
         GetVersionInfoResponse.model_validate(result)
 
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.get_version_info()
                     == GetVersionInfoResponse.model_validate(result)
                 )
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getVersionInfo"
@@ -181,13 +182,13 @@ async def test_get_contract_data(self):
         contract_id = "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY"
         key = stellar_xdr.SCVal(stellar_xdr.SCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE)
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.get_contract_data(contract_id, key)
                 ) == GetLedgerEntriesResponse.model_validate(result).entries[0]
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getLedgerEntries"
@@ -208,11 +209,11 @@ async def test_get_contract_data_not_found(self):
         contract_id = "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY"
         key = stellar_xdr.SCVal(stellar_xdr.SCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE)
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (await client.get_contract_data(contract_id, key)) is None
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getLedgerEntries"
@@ -262,13 +263,13 @@ async def test_get_ledger_entries(self):
             ),
         )
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.get_ledger_entries([key0, key1])
                 ) == GetLedgerEntriesResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getLedgerEntries"
@@ -301,54 +302,55 @@ async def test_get_transaction(self):
         }
         tx_hash = "06dd9ee70bf93bbfe219e2b31363ab5a0361cc6285328592e4d3d1fed4c9025c"
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.get_transaction(tx_hash)
                 ) == GetTransactionResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getTransaction"
         assert request_data["params"] == {"hash": tx_hash}
 
     async def test_get_events(self):
+        events = [
+            {
+                "type": "contract",
+                "ledger": "12739",
+                "ledgerClosedAt": "2023-09-16T06:23:57Z",
+                "contractId": "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY",
+                "id": "0000054713588387840-0000000000",
+                "pagingToken": "0000054713588387840-0000000000",
+                "topic": [
+                    "AAAADwAAAAdDT1VOVEVSAA==",
+                    "AAAADwAAAAlpbmNyZW1lbnQAAAA=",
+                ],
+                "value": "AAAAAwAAAAE=",
+                "inSuccessfulContractCall": True,
+                "txHash": "db86e94aa98b7d38213c041ebbb727fbaabf0b7c435de594f36c2d51fc61926d",
+            },
+            {
+                "type": "contract",
+                "ledger": "12747",
+                "ledgerClosedAt": "2023-09-16T06:24:05Z",
+                "contractId": "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY",
+                "id": "0000054747948126208-0000000000",
+                "pagingToken": "0000054747948126208-0000000000",
+                "topic": [
+                    "AAAADwAAAAdDT1VOVEVSAA==",
+                    "AAAADwAAAAlpbmNyZW1lbnQAAAA=",
+                ],
+                "value": "AAAAAwAAAAI=",
+                "inSuccessfulContractCall": True,
+                "txHash": "db86e94aa98b7d38213c041ebbb727fbaabf0b7c435de594f36c2d51fc61926d",
+            },
+        ]
         result = {
-            "events": [
-                {
-                    "type": "contract",
-                    "ledger": "12739",
-                    "ledgerClosedAt": "2023-09-16T06:23:57Z",
-                    "contractId": "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY",
-                    "id": "0000054713588387840-0000000000",
-                    "pagingToken": "0000054713588387840-0000000000",
-                    "topic": [
-                        "AAAADwAAAAdDT1VOVEVSAA==",
-                        "AAAADwAAAAlpbmNyZW1lbnQAAAA=",
-                    ],
-                    "value": "AAAAAwAAAAE=",
-                    "inSuccessfulContractCall": True,
-                    "txHash": "db86e94aa98b7d38213c041ebbb727fbaabf0b7c435de594f36c2d51fc61926d",
-                },
-                {
-                    "type": "contract",
-                    "ledger": "12747",
-                    "ledgerClosedAt": "2023-09-16T06:24:05Z",
-                    "contractId": "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY",
-                    "id": "0000054747948126208-0000000000",
-                    "pagingToken": "0000054747948126208-0000000000",
-                    "topic": [
-                        "AAAADwAAAAdDT1VOVEVSAA==",
-                        "AAAADwAAAAlpbmNyZW1lbnQAAAA=",
-                    ],
-                    "value": "AAAAAwAAAAI=",
-                    "inSuccessfulContractCall": True,
-                    "txHash": "db86e94aa98b7d38213c041ebbb727fbaabf0b7c435de594f36c2d51fc61926d",
-                },
-            ],
+            "events": events,
             "latestLedger": "187",
-            "cursor": "0000054713588387840-0000000000",
+            "cursor": "0000054747948126208-0000000000",
         }
         data = {
             "jsonrpc": "2.0",
@@ -368,17 +370,17 @@ async def test_get_events(self):
                 ],
             )
         ]
-        GetEventsResponse.model_validate(result)
+        events_response = GetEventsResponse.model_validate(result)
         cursor = "0000054713588387839-0000000000"
         limit = 10
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
-                    await client.get_events(start_ledger, filters, cursor, limit)
-                ) == GetEventsResponse.model_validate(result)
+                    await client.get_events(start_ledger, filters, limit=limit)
+                ) == events_response
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getEvents"
@@ -394,10 +396,50 @@ async def test_get_events(self):
                     "type": "contract",
                 }
             ],
-            "pagination": {"cursor": "0000054713588387839-0000000000", "limit": 10},
+            "pagination": {"cursor": None, "limit": 10},
             "startLedger": 100,
         }
 
+        # simulate the advance of one ledger
+        cursor = events_response.cursor
+        result = {
+            "events": [],
+            "latestLedger": "188",
+            "cursor": "0000054747948126210-0000000000",
+        }
+        data = {
+            "jsonrpc": "2.0",
+            "id": "198cb1a8-9104-4446-a269-88bf000c3986",
+            "result": result,
+        }
+        events_response = GetEventsResponse.model_validate(result)
+        with aioresponses() as m:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
+                assert (
+                    await client.get_events(filters=filters, cursor=cursor, limit=limit)
+                ) == events_response
+
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
+        assert len(request_data["id"]) == 32
+        assert request_data["jsonrpc"] == "2.0"
+        assert request_data["method"] == "getEvents"
+        assert request_data["params"] == {
+            "filters": [
+                {
+                    "contractIds": [
+                        "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY"
+                    ],
+                    "topics": [
+                        ["AAAADwAAAAdDT1VOVEVSAA==", "AAAADwAAAAlpbmNyZW1lbnQAAAA="]
+                    ],
+                    "type": "contract",
+                }
+            ],
+            "pagination": {"cursor": "0000054747948126208-0000000000", "limit": 10},
+            "startLedger": None,
+        }
+
     async def test_get_latest_ledger(self):
         result = {
             "id": "e73d7654b72daa637f396669182c6072549736a9e3b6fcb8e685adb61f8c910a",
@@ -410,13 +452,13 @@ async def test_get_latest_ledger(self):
             "result": result,
         }
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.get_latest_ledger()
                 ) == GetLatestLedgerResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getLatestLedger"
@@ -469,13 +511,13 @@ async def test_get_fee_stats(self):
             "result": result,
         }
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.get_fee_stats()
                 ) == GetFeeStatsResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getFeeStats"
@@ -578,13 +620,13 @@ async def test_get_transactions(self):
         GetTransactionsResponse.model_validate(result)
         limit = 5
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.get_transactions(start_ledger, None, limit)
                 ) == GetTransactionsResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getTransactions"
@@ -628,13 +670,13 @@ async def test_get_ledgers(self):
         GetLedgersResponse.model_validate(result)
         limit = 2
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.get_ledgers(start_ledger, None, limit)
                 ) == GetLedgersResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "getLedgers"
@@ -676,13 +718,13 @@ async def test_simulate_transaction(self):
         }
         transaction = _build_soroban_transaction(None, [])
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.simulate_transaction(transaction)
                 ) == SimulateTransactionResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "simulateTransaction"
@@ -716,15 +758,15 @@ async def test_simulate_transaction_with_addl_resources(self):
         }
         transaction = _build_soroban_transaction(None, [])
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.simulate_transaction(
                         transaction, ResourceLeeway(1000000)
                     )
                 ) == SimulateTransactionResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "simulateTransaction"
@@ -758,8 +800,8 @@ async def test_prepare_transaction_without_auth_and_soroban_data(self):
 
         transaction = _build_soroban_transaction(None, [])
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 new_transaction = await client.prepare_transaction(transaction)
         expected_transaction = copy.deepcopy(transaction)
         expected_transaction.transaction.fee += int(data["result"]["minResourceFee"])
@@ -813,8 +855,8 @@ async def test_prepare_transaction_with_soroban_data(self):
         )  # soroban_data will be overwritten by the response
         transaction = _build_soroban_transaction(soroban_data, [])
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 new_transaction = await client.prepare_transaction(transaction)
         expected_transaction = copy.deepcopy(transaction)
         expected_transaction.transaction.fee = 50000 + int(
@@ -876,8 +918,8 @@ async def test_prepare_transaction_with_auth(self):
 
         transaction = _build_soroban_transaction(None, [auth])
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 new_transaction = await client.prepare_transaction(transaction)
         expected_transaction = copy.deepcopy(transaction)
         expected_transaction.transaction.fee += int(data["result"]["minResourceFee"])
@@ -906,12 +948,12 @@ async def test_prepare_transaction_error_resp_prepare_transaction_exception_rais
         }
         transaction = _build_soroban_transaction(None, [])
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
+            m.post(RPC_URL, payload=data)
             with pytest.raises(
                 PrepareTransactionException,
                 match="Simulation transaction failed, the response contains error information.",
             ) as e:
-                async with SorobanServerAsync(PRC_URL) as client:
+                async with SorobanServerAsync(RPC_URL) as client:
                     await client.prepare_transaction(transaction)
             assert (
                 e.value.simulate_transaction_response
@@ -938,12 +980,12 @@ async def test_prepare_transaction_invalid_results_value_raise(
         }
         transaction = _build_soroban_transaction(None, [])
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
+            m.post(RPC_URL, payload=data)
             with pytest.raises(
                 ValueError,
                 match="Simulation results invalid",
             ) as e:
-                async with SorobanServerAsync(PRC_URL) as client:
+                async with SorobanServerAsync(RPC_URL) as client:
                     await client.prepare_transaction(transaction)
 
     async def test_send_transaction(self):
@@ -961,13 +1003,13 @@ async def test_send_transaction(self):
 
         transaction = _build_soroban_transaction(None, [])
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.send_transaction(transaction)
                 ) == SendTransactionResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "sendTransaction"
@@ -993,13 +1035,13 @@ async def test_send_transaction_error(self):
 
         transaction = _build_soroban_transaction(None, [])
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
-            async with SorobanServerAsync(PRC_URL) as client:
+            m.post(RPC_URL, payload=data)
+            async with SorobanServerAsync(RPC_URL) as client:
                 assert (
                     await client.send_transaction(transaction)
                 ) == SendTransactionResponse.model_validate(result)
 
-        request_data = m.requests[("POST", URL(PRC_URL))][0].kwargs["json"]
+        request_data = m.requests[("POST", URL(RPC_URL))][0].kwargs["json"]
         assert len(request_data["id"]) == 32
         assert request_data["jsonrpc"] == "2.0"
         assert request_data["method"] == "sendTransaction"
@@ -1017,14 +1059,25 @@ async def test_soroban_rpc_error_response_raise(self):
             },
         }
         with aioresponses() as m:
-            m.post(PRC_URL, payload=data)
+            m.post(RPC_URL, payload=data)
             with pytest.raises(SorobanRpcErrorResponse) as e:
-                async with SorobanServerAsync(PRC_URL) as client:
+                async with SorobanServerAsync(RPC_URL) as client:
                     await client.get_health()
             assert e.value.code == -32601
             assert e.value.message == "method not found"
             assert e.value.data == "mockTest"
 
+    async def test_pagination_start_ledger_and_cursor_raise(self):
+        with pytest.raises(ValidationError) as e:
+            async with SorobanServerAsync(RPC_URL) as client:
+                await client.get_transactions(
+                    start_ledger=67, cursor="8111217537191937", limit=1
+                )
+        assert e.value.error_count() == 1
+        val_error = e.value.errors()[0]
+        assert val_error["type"] == "value_error"
+        assert val_error["msg"].endswith("start_ledger and cursor cannot both be set")
+
 
 def _build_soroban_transaction(
     soroban_data: Optional[stellar_xdr.SorobanTransactionData],
diff --git a/tests/test_soroban_server_sync.py b/tests/test_soroban_server_sync.py
index fdc98db7..9c39404e 100644
--- a/tests/test_soroban_server_sync.py
+++ b/tests/test_soroban_server_sync.py
@@ -2,6 +2,7 @@
 
 import pytest
 import requests_mock
+from pydantic import ValidationError
 
 from stellar_sdk import Account, Keypair, Network, TransactionBuilder, scval
 from stellar_sdk import xdr as stellar_xdr
@@ -15,7 +16,7 @@
 from stellar_sdk.soroban_rpc import *
 from stellar_sdk.soroban_server import SorobanServer
 
-PRC_URL = "https://example.com/soroban_rpc"
+RPC_URL = "https://example.com/soroban_rpc"
 
 
 class TestSorobanServer:
@@ -38,8 +39,8 @@ def test_load_account(self):
         }
         account_id = "GDAT5HWTGIU4TSSZ4752OUC4SABDLTLZFRPZUJ3D6LKBNEPA7V2CIG54"
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).load_account(account_id) == Account(
+            m.post(RPC_URL, json=data)
+            assert SorobanServer(RPC_URL).load_account(account_id) == Account(
                 account_id, 3418793967628
             )
 
@@ -60,12 +61,12 @@ def test_load_account_not_found_raise(self):
         }
         account_id = "GDAT5HWTGIU4TSSZ4752OUC4SABDLTLZFRPZUJ3D6LKBNEPA7V2CIG54"
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             with pytest.raises(
                 AccountNotFoundException,
                 match=f"Account not found, account_id: {account_id}",
             ):
-                SorobanServer(PRC_URL).load_account(account_id)
+                SorobanServer(RPC_URL).load_account(account_id)
 
         request_data = m.last_request.json()
         assert len(request_data["id"]) == 32
@@ -88,9 +89,9 @@ def test_get_health(self):
             "result": result,
         }
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             assert SorobanServer(
-                PRC_URL
+                RPC_URL
             ).get_health() == GetHealthResponse.model_validate(result)
 
         request_data = m.last_request.json()
@@ -114,9 +115,9 @@ def test_get_network(self):
         GetNetworkResponse.model_validate(result)
 
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             assert SorobanServer(
-                PRC_URL
+                RPC_URL
             ).get_network() == GetNetworkResponse.model_validate(result)
 
         request_data = m.last_request.json()
@@ -142,9 +143,9 @@ def test_get_version_info(self):
         GetVersionInfoResponse.model_validate(result)
 
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             assert SorobanServer(
-                PRC_URL
+                RPC_URL
             ).get_version_info() == GetVersionInfoResponse.model_validate(result)
 
         request_data = m.last_request.json()
@@ -174,9 +175,9 @@ def test_get_contract_data(self):
         contract_id = "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY"
         key = stellar_xdr.SCVal(stellar_xdr.SCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE)
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             assert (
-                SorobanServer(PRC_URL).get_contract_data(contract_id, key)
+                SorobanServer(RPC_URL).get_contract_data(contract_id, key)
                 == GetLedgerEntriesResponse.model_validate(result).entries[0]
             )
 
@@ -201,8 +202,8 @@ def test_get_contract_data_not_found(self):
         contract_id = "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY"
         key = stellar_xdr.SCVal(stellar_xdr.SCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE)
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).get_contract_data(contract_id, key) is None
+            m.post(RPC_URL, json=data)
+            assert SorobanServer(RPC_URL).get_contract_data(contract_id, key) is None
 
         request_data = m.last_request.json()
         assert len(request_data["id"]) == 32
@@ -254,8 +255,8 @@ def test_get_ledger_entries(self):
             ),
         )
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).get_ledger_entries(
+            m.post(RPC_URL, json=data)
+            assert SorobanServer(RPC_URL).get_ledger_entries(
                 [key0, key1]
             ) == GetLedgerEntriesResponse.model_validate(result)
 
@@ -292,8 +293,8 @@ def test_get_transaction(self):
         }
         tx_hash = "06dd9ee70bf93bbfe219e2b31363ab5a0361cc6285328592e4d3d1fed4c9025c"
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).get_transaction(
+            m.post(RPC_URL, json=data)
+            assert SorobanServer(RPC_URL).get_transaction(
                 tx_hash
             ) == GetTransactionResponse.model_validate(result)
 
@@ -304,41 +305,42 @@ def test_get_transaction(self):
         assert request_data["params"] == {"hash": tx_hash}
 
     def test_get_events(self):
+        events = [
+            {
+                "type": "contract",
+                "ledger": "12739",
+                "ledgerClosedAt": "2023-09-16T06:23:57Z",
+                "contractId": "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY",
+                "id": "0000054713588387840-0000000000",
+                "pagingToken": "0000054713588387840-0000000000",
+                "topic": [
+                    "AAAADwAAAAdDT1VOVEVSAA==",
+                    "AAAADwAAAAlpbmNyZW1lbnQAAAA=",
+                ],
+                "value": "AAAAAwAAAAE=",
+                "inSuccessfulContractCall": True,
+                "txHash": "db86e94aa98b7d38213c041ebbb727fbaabf0b7c435de594f36c2d51fc61926d",
+            },
+            {
+                "type": "contract",
+                "ledger": "12747",
+                "ledgerClosedAt": "2023-09-16T06:24:05Z",
+                "contractId": "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY",
+                "id": "0000054747948126208-0000000000",
+                "pagingToken": "0000054747948126208-0000000000",
+                "topic": [
+                    "AAAADwAAAAdDT1VOVEVSAA==",
+                    "AAAADwAAAAlpbmNyZW1lbnQAAAA=",
+                ],
+                "value": "AAAAAwAAAAI=",
+                "inSuccessfulContractCall": True,
+                "txHash": "db86e94aa98b7d38213c041ebbb727fbaabf0b7c435de594f36c2d51fc61926d",
+            },
+        ]
         result = {
-            "events": [
-                {
-                    "type": "contract",
-                    "ledger": "12739",
-                    "ledgerClosedAt": "2023-09-16T06:23:57Z",
-                    "contractId": "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY",
-                    "id": "0000054713588387840-0000000000",
-                    "pagingToken": "0000054713588387840-0000000000",
-                    "topic": [
-                        "AAAADwAAAAdDT1VOVEVSAA==",
-                        "AAAADwAAAAlpbmNyZW1lbnQAAAA=",
-                    ],
-                    "value": "AAAAAwAAAAE=",
-                    "inSuccessfulContractCall": True,
-                    "txHash": "db86e94aa98b7d38213c041ebbb727fbaabf0b7c435de594f36c2d51fc61926d",
-                },
-                {
-                    "type": "contract",
-                    "ledger": "12747",
-                    "ledgerClosedAt": "2023-09-16T06:24:05Z",
-                    "contractId": "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY",
-                    "id": "0000054747948126208-0000000000",
-                    "pagingToken": "0000054747948126208-0000000000",
-                    "topic": [
-                        "AAAADwAAAAdDT1VOVEVSAA==",
-                        "AAAADwAAAAlpbmNyZW1lbnQAAAA=",
-                    ],
-                    "value": "AAAAAwAAAAI=",
-                    "inSuccessfulContractCall": True,
-                    "txHash": "db86e94aa98b7d38213c041ebbb727fbaabf0b7c435de594f36c2d51fc61926d",
-                },
-            ],
+            "events": events,
             "latestLedger": "187",
-            "cursor": "0000054713588387840-0000000000",
+            "cursor": "0000054747948126208-0000000000",
         }
         data = {
             "jsonrpc": "2.0",
@@ -358,14 +360,14 @@ def test_get_events(self):
                 ],
             )
         ]
-        GetEventsResponse.model_validate(result)
-        cursor = "0000054713588387839-0000000000"
+        events_response = GetEventsResponse.model_validate(result)
         limit = 10
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).get_events(
-                start_ledger, filters, cursor, limit
-            ) == GetEventsResponse.model_validate(result)
+            m.post(RPC_URL, json=data)
+            assert (
+                SorobanServer(RPC_URL).get_events(start_ledger, filters, limit=limit)
+                == events_response
+            )
 
         request_data = m.last_request.json()
         assert len(request_data["id"]) == 32
@@ -383,10 +385,52 @@ def test_get_events(self):
                     "type": "contract",
                 }
             ],
-            "pagination": {"cursor": "0000054713588387839-0000000000", "limit": 10},
+            "pagination": {"cursor": None, "limit": 10},
             "startLedger": 100,
         }
 
+        # simulate the advance of one ledger
+        cursor = events_response.cursor
+        result = {
+            "events": [],
+            "latestLedger": "188",
+            "cursor": "0000054747948126210-0000000000",
+        }
+        data = {
+            "jsonrpc": "2.0",
+            "id": "198cb1a8-9104-4446-a269-88bf000c3986",
+            "result": result,
+        }
+        events_response = GetEventsResponse.model_validate(result)
+        with requests_mock.Mocker() as m:
+            m.post(RPC_URL, json=data)
+            assert (
+                SorobanServer(RPC_URL).get_events(
+                    filters=filters, cursor=cursor, limit=limit
+                )
+                == events_response
+            )
+
+        request_data = m.last_request.json()
+        assert len(request_data["id"]) == 32
+        assert request_data["jsonrpc"] == "2.0"
+        assert request_data["method"] == "getEvents"
+        assert request_data["params"] == {
+            "filters": [
+                {
+                    "contractIds": [
+                        "CBNYUGHFAIWK3HOINA2OIGOOBMQU4D3MPQWFYBTUYY5WY4FVDO2GWXUY"
+                    ],
+                    "topics": [
+                        ["AAAADwAAAAdDT1VOVEVSAA==", "AAAADwAAAAlpbmNyZW1lbnQAAAA="]
+                    ],
+                    "type": "contract",
+                }
+            ],
+            "pagination": {"cursor": "0000054747948126208-0000000000", "limit": 10},
+            "startLedger": None,
+        }
+
     def test_get_latest_ledger(self):
         result = {
             "id": "e73d7654b72daa637f396669182c6072549736a9e3b6fcb8e685adb61f8c910a",
@@ -399,9 +443,9 @@ def test_get_latest_ledger(self):
             "result": result,
         }
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             assert SorobanServer(
-                PRC_URL
+                RPC_URL
             ).get_latest_ledger() == GetLatestLedgerResponse.model_validate(result)
 
         request_data = m.last_request.json()
@@ -457,9 +501,9 @@ def test_get_fee_stats(self):
             "result": result,
         }
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             assert SorobanServer(
-                PRC_URL
+                RPC_URL
             ).get_fee_stats() == GetFeeStatsResponse.model_validate(result)
 
         request_data = m.last_request.json()
@@ -562,13 +606,14 @@ def test_get_transactions(self):
         }
 
         start_ledger = 1888539
-        GetTransactionsResponse.model_validate(result)
+        txs_response = GetTransactionsResponse.model_validate(result)
         limit = 5
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).get_transactions(
-                start_ledger, None, limit
-            ) == GetTransactionsResponse.model_validate(result)
+            m.post(RPC_URL, json=data)
+            assert (
+                SorobanServer(RPC_URL).get_transactions(start_ledger, None, limit)
+                == txs_response
+            )
 
         request_data = m.last_request.json()
         assert len(request_data["id"]) == 32
@@ -579,6 +624,26 @@ def test_get_transactions(self):
             "pagination": {"cursor": None, "limit": 5},
         }
 
+    def test_get_transactions_without_args(self):
+        data = {
+            "jsonrpc": "2.0",
+            "id": "198cb1a8-9104-4446-a269-88bf000c2721",
+            "result": {
+                "transactions": [],
+                "latestLedger": 1888542,
+                "latestLedgerCloseTimestamp": 1717166057,
+                "oldestLedger": 1871263,
+                "oldestLedgerCloseTimestamp": 1717075350,
+                "cursor": "8111217537191937",
+            },
+        }
+        # test that all arguments are optional
+        with requests_mock.Mocker() as m:
+            m.post(RPC_URL, json=data)
+            assert isinstance(
+                SorobanServer(RPC_URL).get_transactions(), GetTransactionsResponse
+            )
+
     def test_get_ledgers(self):
         result = {
             "ledgers": [
@@ -611,13 +676,14 @@ def test_get_ledgers(self):
         }
 
         start_ledger = 10
-        GetLedgersResponse.model_validate(result)
+        ledgers_response = GetLedgersResponse.model_validate(result)
         limit = 2
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).get_ledgers(
-                start_ledger, None, limit
-            ) == GetLedgersResponse.model_validate(result)
+            m.post(RPC_URL, json=data)
+            assert (
+                SorobanServer(RPC_URL).get_ledgers(start_ledger, None, limit)
+                == ledgers_response
+            )
 
         request_data = m.last_request.json()
         assert len(request_data["id"]) == 32
@@ -628,6 +694,24 @@ def test_get_ledgers(self):
             "pagination": {"cursor": None, "limit": 2},
         }
 
+    def test_get_ledgers_without_args(self):
+        data = {
+            "jsonrpc": "2.0",
+            "id": "198cb1a8-9104-4446-a269-88bf000c2721",
+            "result": {
+                "ledgers": [],
+                "latestLedger": 113,
+                "latestLedgerCloseTime": 1731554518,
+                "oldestLedger": 8,
+                "oldestLedgerCloseTime": 1731554412,
+                "cursor": "11",
+            },
+        }
+        # test that all arguments are optional
+        with requests_mock.Mocker() as m:
+            m.post(RPC_URL, json=data)
+            assert isinstance(SorobanServer(RPC_URL).get_ledgers(), GetLedgersResponse)
+
     def test_simulate_transaction(self):
         result = {
             "transactionData": "AAAAAAAAAAIAAAAGAAAAAcWLK/vE8FTnMk9r8gytPgJuQbutGm0gw9fUkY3tFlQRAAAAFAAAAAEAAAAAAAAAB300Hyg0HZG+Qie3zvsxLvugrNtFqd3AIntWy9bg2YvZAAAAAAAAAAEAAAAGAAAAAcWLK/vE8FTnMk9r8gytPgJuQbutGm0gw9fUkY3tFlQRAAAAEAAAAAEAAAACAAAADwAAAAdDb3VudGVyAAAAABIAAAAAAAAAAFi3xKLI8peqjz0kcSgf38zsr+SOVmMxPsGOEqc+ypihAAAAAQAAAAAAFcLDAAAF8AAAAQgAAAMcAAAAAAAAAJw=",
@@ -661,8 +745,8 @@ def test_simulate_transaction(self):
         }
         transaction = _build_soroban_transaction(None, [])
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).simulate_transaction(
+            m.post(RPC_URL, json=data)
+            assert SorobanServer(RPC_URL).simulate_transaction(
                 transaction
             ) == SimulateTransactionResponse.model_validate(result)
 
@@ -700,8 +784,8 @@ def test_simulate_transaction_with_addl_resources(self):
         }
         transaction = _build_soroban_transaction(None, [])
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).simulate_transaction(
+            m.post(RPC_URL, json=data)
+            assert SorobanServer(RPC_URL).simulate_transaction(
                 transaction, ResourceLeeway(1000000)
             ) == SimulateTransactionResponse.model_validate(result)
 
@@ -739,8 +823,8 @@ def test_prepare_transaction_without_auth_and_soroban_data(self):
 
         transaction = _build_soroban_transaction(None, [])
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            new_transaction = SorobanServer(PRC_URL).prepare_transaction(transaction)
+            m.post(RPC_URL, json=data)
+            new_transaction = SorobanServer(RPC_URL).prepare_transaction(transaction)
         expected_transaction = copy.deepcopy(transaction)
         expected_transaction.transaction.fee += int(data["result"]["minResourceFee"])
         expected_transaction.transaction.soroban_data = (
@@ -793,8 +877,8 @@ def test_prepare_transaction_with_soroban_data(self):
         )  # soroban_data will be overwritten by the response
         transaction = _build_soroban_transaction(soroban_data, [])
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            new_transaction = SorobanServer(PRC_URL).prepare_transaction(transaction)
+            m.post(RPC_URL, json=data)
+            new_transaction = SorobanServer(RPC_URL).prepare_transaction(transaction)
         expected_transaction = copy.deepcopy(transaction)
         expected_transaction.transaction.fee = 50000 + int(
             data["result"]["minResourceFee"]
@@ -855,8 +939,8 @@ def test_prepare_transaction_with_auth(self):
 
         transaction = _build_soroban_transaction(None, [auth])
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            new_transaction = SorobanServer(PRC_URL).prepare_transaction(transaction)
+            m.post(RPC_URL, json=data)
+            new_transaction = SorobanServer(RPC_URL).prepare_transaction(transaction)
         expected_transaction = copy.deepcopy(transaction)
         expected_transaction.transaction.fee += int(data["result"]["minResourceFee"])
         expected_transaction.transaction.soroban_data = (
@@ -882,12 +966,12 @@ def test_prepare_transaction_error_resp_prepare_transaction_exception_raise(self
         }
         transaction = _build_soroban_transaction(None, [])
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             with pytest.raises(
                 PrepareTransactionException,
                 match="Simulation transaction failed, the response contains error information.",
             ) as e:
-                SorobanServer(PRC_URL).prepare_transaction(transaction)
+                SorobanServer(RPC_URL).prepare_transaction(transaction)
             assert (
                 e.value.simulate_transaction_response
                 == SimulateTransactionResponse.model_validate(data["result"])
@@ -913,12 +997,12 @@ def test_prepare_transaction_invalid_results_value_raise(
         }
         transaction = _build_soroban_transaction(None, [])
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             with pytest.raises(
                 ValueError,
                 match="Simulation results invalid",
             ) as e:
-                SorobanServer(PRC_URL).prepare_transaction(transaction)
+                SorobanServer(RPC_URL).prepare_transaction(transaction)
 
     def test_send_transaction(self):
         result = {
@@ -935,8 +1019,8 @@ def test_send_transaction(self):
 
         transaction = _build_soroban_transaction(None, [])
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).send_transaction(
+            m.post(RPC_URL, json=data)
+            assert SorobanServer(RPC_URL).send_transaction(
                 transaction
             ) == SendTransactionResponse.model_validate(result)
 
@@ -966,8 +1050,8 @@ def test_send_transaction_error(self):
 
         transaction = _build_soroban_transaction(None, [])
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
-            assert SorobanServer(PRC_URL).send_transaction(
+            m.post(RPC_URL, json=data)
+            assert SorobanServer(RPC_URL).send_transaction(
                 transaction
             ) == SendTransactionResponse.model_validate(result)
 
@@ -989,13 +1073,23 @@ def test_soroban_rpc_error_response_raise(self):
             },
         }
         with requests_mock.Mocker() as m:
-            m.post(PRC_URL, json=data)
+            m.post(RPC_URL, json=data)
             with pytest.raises(SorobanRpcErrorResponse) as e:
-                SorobanServer(PRC_URL).get_health()
+                SorobanServer(RPC_URL).get_health()
             assert e.value.code == -32601
             assert e.value.message == "method not found"
             assert e.value.data == "mockTest"
 
+    def test_pagination_start_ledger_and_cursor_raise(self):
+        with pytest.raises(ValidationError) as e:
+            SorobanServer(RPC_URL).get_transactions(
+                start_ledger=67, cursor="8111217537191937", limit=1
+            )
+        assert e.value.error_count() == 1
+        val_error = e.value.errors()[0]
+        assert val_error["type"] == "value_error"
+        assert val_error["msg"].endswith("start_ledger and cursor cannot both be set")
+
 
 def _build_soroban_transaction(
     soroban_data: Optional[stellar_xdr.SorobanTransactionData],


Reviews:
user: login: overcat
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-10T14:59:35Z
user: login: aolieman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T18:26:13Z
user: login: overcat
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-11T06:35:45Z
user: login: aolieman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T12:07:55Z
user: login: overcat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T12:09:39Z
user: login: overcat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T12:11:44Z
user: login: aolieman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T12:12:53Z
user: login: overcat
body: 🎉 Thank you for your contribution.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T14:00:39Z

Review comments:
diff_hunk: @@ -376,14 +387,13 @@ class GetFeeStatsResponse(BaseModel):
 
 
 # get_transactions
-class GetTransactionsRequest(BaseModel):
+class GetTransactionsRequest(PaginationMixin, BaseModel):
     """Request for JSON-RPC method getTransactions.
 
     See `getTransactions documentation <https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions>`__ for
     more information."""
 
     start_ledger: int = Field(alias="startLedger")
path: stellar_sdk/soroban_rpc.py
user: login: overcat
body: Do we need to add Optional here? Can you check it?
created_at: 2025-04-10T14:58:48Z
author_association: MEMBER
diff_hunk: @@ -430,14 +440,13 @@ class GetVersionInfoResponse(BaseModel):
 
 
 # get_ledgers
-class GetLedgersRequest(BaseModel):
+class GetLedgersRequest(PaginationMixin, BaseModel):
     """Request for JSON-RPC method getLedgers.
 
     See `getLedgers documentation <https://developers.stellar.org/docs/data/rpc/api-reference/methods/getLedgers>`__ for
     more information."""
 
     start_ledger: int = Field(alias="startLedger")
path: stellar_sdk/soroban_rpc.py
user: login: overcat
body: It's the same here.
created_at: 2025-04-10T14:59:28Z
author_association: MEMBER
diff_hunk: @@ -376,14 +387,13 @@ class GetFeeStatsResponse(BaseModel):
 
 
 # get_transactions
-class GetTransactionsRequest(BaseModel):
+class GetTransactionsRequest(PaginationMixin, BaseModel):
     """Request for JSON-RPC method getTransactions.
 
     See `getTransactions documentation <https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions>`__ for
     more information."""
 
     start_ledger: int = Field(alias="startLedger")
path: stellar_sdk/soroban_rpc.py
user: login: aolieman
body: Good catch! I've added tests to prevent regression.
created_at: 2025-04-10T18:26:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,16 +75,27 @@ class PaginationOptions(BaseModel):
     limit: Optional[int] = None
 
 
-class GetEventsRequest(BaseModel):
+class PaginationMixin:
+    pagination: Optional[PaginationOptions] = None
+
+    @model_validator(mode="after")
+    def verify_ledger_or_cursor(self) -> Self:
path: stellar_sdk/soroban_rpc.py
user: login: overcat
body: ```suggestion
    def verify_ledger_or_cursor(self) -> "PaginationMixin":
```

We need to support lower versions of Python.
created_at: 2025-04-11T06:35:38Z
author_association: MEMBER
diff_hunk: @@ -75,16 +75,27 @@ class PaginationOptions(BaseModel):
     limit: Optional[int] = None
 
 
-class GetEventsRequest(BaseModel):
+class PaginationMixin:
+    pagination: Optional[PaginationOptions] = None
+
+    @model_validator(mode="after")
+    def verify_ledger_or_cursor(self) -> Self:
path: stellar_sdk/soroban_rpc.py
user: login: aolieman
body: Would it be ok to use `typing_extensions` instead? It is guaranteed to be available because Pydantic depends on it.
The advantage would be that it's clearer and that it will be more obvious what needs to happen as you drop support for older Python versions.
created_at: 2025-04-11T12:07:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,16 +75,27 @@ class PaginationOptions(BaseModel):
     limit: Optional[int] = None
 
 
-class GetEventsRequest(BaseModel):
+class PaginationMixin:
+    pagination: Optional[PaginationOptions] = None
+
+    @model_validator(mode="after")
+    def verify_ledger_or_cursor(self) -> Self:
path: stellar_sdk/soroban_rpc.py
user: login: overcat
body: 👍 good idea.
created_at: 2025-04-11T12:09:39Z
author_association: MEMBER
diff_hunk: @@ -75,16 +75,27 @@ class PaginationOptions(BaseModel):
     limit: Optional[int] = None
 
 
-class GetEventsRequest(BaseModel):
+class PaginationMixin:
+    pagination: Optional[PaginationOptions] = None
+
+    @model_validator(mode="after")
+    def verify_ledger_or_cursor(self) -> Self:
path: stellar_sdk/soroban_rpc.py
user: login: overcat
body: Would it be better to add `typing_extensions` to the dependencies? Because we cannot guarantee that `pydantic` will always depend on it.
created_at: 2025-04-11T12:11:44Z
author_association: MEMBER
diff_hunk: @@ -75,16 +75,27 @@ class PaginationOptions(BaseModel):
     limit: Optional[int] = None
 
 
-class GetEventsRequest(BaseModel):
+class PaginationMixin:
+    pagination: Optional[PaginationOptions] = None
+
+    @model_validator(mode="after")
+    def verify_ledger_or_cursor(self) -> Self:
path: stellar_sdk/soroban_rpc.py
user: login: aolieman
body: ok, i'll do that
created_at: 2025-04-11T12:12:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: overcat
created_at: 2025-04-10T14:09:38Z
body: Hi @aolieman, please remember to update the `CHANGELOG.md`
author_association: MEMBER
user: login: aolieman
created_at: 2025-04-10T14:47:27Z
body: I've updated the changelog. This might be complete. What do you think?
author_association: CONTRIBUTOR
user: login: overcat
created_at: 2025-04-10T14:51:25Z
body: > I've updated the changelog. This might be complete. What do you think?

Hi @aolieman, it's late here, so I need to wait until tomorrow to review it.
author_association: MEMBER
user: login: overcat
created_at: 2025-04-10T14:53:11Z
body: I think you need to run `pre-commit run --all-files`.

https://pre-commit.com/
author_association: MEMBER
user: login: aolieman
created_at: 2025-04-10T14:54:18Z
body: Alright, I understand. I'll have a look at the CI output in the meantime.

Depending on which type checker I use, I still get warnings for optional arguments that don't have the None type:

```python
        # pyright warns about all these default values
        start_ledger: int = None,
        filters: Sequence[EventFilter] = None,
        cursor: str = None,
        limit: int = None,
```

Is this intentional? I can change it if you want, but it's not new and I don't think it's a big issue for anyone.
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-10T14:55:19Z
body: ## [Codecov](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.28%. Comparing base [(`08622be`)](https://app.codecov.io/gh/StellarCN/py-stellar-base/commit/08622bebdce4eea45327958f73b0525a66139ef2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN) to head [(`8db5c6e`)](https://app.codecov.io/gh/StellarCN/py-stellar-base/commit/8db5c6e1269a756eca8e5510f7307dc83d5e1576?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN).
> Report is 1 commits behind head on main.

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


[![Impacted file tree graph](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032/graphs/tree.svg?width=650&height=150&src=pr&token=kHAKcotfqv&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN)](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN)

```diff
@@           Coverage Diff           @@
##             main    #1032   +/-   ##
=======================================
  Coverage   98.27%   98.28%           
=======================================
  Files         262      262           
  Lines       14250    14308   +58     
=======================================
+ Hits        14004    14062   +58     
  Misses        246      246           
```

| [Flag](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN) | `98.28% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files with missing lines](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN) | Coverage Δ | |
|---|---|---|
| [stellar\_sdk/soroban\_rpc.py](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032?src=pr&el=tree&filepath=stellar_sdk%2Fsoroban_rpc.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN#diff-c3RlbGxhcl9zZGsvc29yb2Jhbl9ycGMucHk=) | `100.00% <100.00%> (ø)` | |
| [stellar\_sdk/soroban\_server.py](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032?src=pr&el=tree&filepath=stellar_sdk%2Fsoroban_server.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN#diff-c3RlbGxhcl9zZGsvc29yb2Jhbl9zZXJ2ZXIucHk=) | `97.27% <ø> (ø)` | |
| [stellar\_sdk/soroban\_server\_async.py](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032?src=pr&el=tree&filepath=stellar_sdk%2Fsoroban_server_async.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN#diff-c3RlbGxhcl9zZGsvc29yb2Jhbl9zZXJ2ZXJfYXN5bmMucHk=) | `97.27% <ø> (ø)` | |
| [tests/test\_soroban\_server\_async.py](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032?src=pr&el=tree&filepath=tests%2Ftest_soroban_server_async.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN#diff-dGVzdHMvdGVzdF9zb3JvYmFuX3NlcnZlcl9hc3luYy5weQ==) | `100.00% <100.00%> (ø)` | |
| [tests/test\_soroban\_server\_sync.py](https://app.codecov.io/gh/StellarCN/py-stellar-base/pull/1032?src=pr&el=tree&filepath=tests%2Ftest_soroban_server_sync.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=StellarCN#diff-dGVzdHMvdGVzdF9zb3JvYmFuX3NlcnZlcl9zeW5jLnB5) | `100.00% <100.00%> (ø)` | |

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

- :snowflake: [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: overcat
created_at: 2025-04-10T14:56:16Z
body: > Alright, I understand. I'll have a look at the CI output in the meantime.
> 
> Depending on which type checker I use, I still get warnings for optional arguments that don't have the None type:
> 
> ```python
>         # pyright warns about all these default values
>         start_ledger: int = None,
>         filters: Sequence[EventFilter] = None,
>         cursor: str = None,
>         limit: int = None,
> ```
> 
> Is this intentional? I can change it if you want, but it's not new and I don't think it's a big issue for anyone.

I don't think this is a problem; let's keep it as it is in this PR.
author_association: MEMBER
user: login: overcat
created_at: 2025-04-11T06:41:52Z
body: 👍 The other parts look good; we just need to fix the issue that it cannot run on py 3.8 to py 3.10 before we can merge it.
author_association: MEMBER
user: login: aolieman
created_at: 2025-04-11T14:19:36Z
body: It was a pleasure working with you!
author_association: CONTRIBUTOR