From 52e6008af546c6569ad8e2eb9fc95af1ba79c67b Mon Sep 17 00:00:00 2001 From: Zbynek Masler Date: Fri, 21 Aug 2026 19:46:05 +0200 Subject: [PATCH 1/4] fix: paginate Helius stake account requests --- .../data/solana/staked_solana_response.json | 79 ++++++++------- blockapi/test/v2/api/test_solana.py | 98 ++++++++++++++++++- blockapi/v2/api/solana.py | 58 +++++++---- 3 files changed, 177 insertions(+), 58 deletions(-) diff --git a/blockapi/test/v2/api/data/solana/staked_solana_response.json b/blockapi/test/v2/api/data/solana/staked_solana_response.json index 3e1a5c8d..f8f3b9bf 100644 --- a/blockapi/test/v2/api/data/solana/staked_solana_response.json +++ b/blockapi/test/v2/api/data/solana/staked_solana_response.json @@ -1,47 +1,50 @@ { "jsonrpc": "2.0", - "result": [ - { - "account": { - "data": { - "parsed": { - "info": { - "meta": { - "authorized": { - "staker": "6T4ddWpeAvpU49aePYV6YiWPkMeNbBuWwGtPTphZ3xn6", - "withdrawer": "6T4ddWpeAvpU49aePYV6YiWPkMeNbBuWwGtPTphZ3xn6" + "result": { + "accounts": [ + { + "account": { + "data": { + "parsed": { + "info": { + "meta": { + "authorized": { + "staker": "6T4ddWpeAvpU49aePYV6YiWPkMeNbBuWwGtPTphZ3xn6", + "withdrawer": "6T4ddWpeAvpU49aePYV6YiWPkMeNbBuWwGtPTphZ3xn6" + }, + "lockup": { + "custodian": "11111111111111111111111111111111", + "epoch": 0, + "unixTimestamp": 0 + }, + "rentExemptReserve": "2282880" }, - "lockup": { - "custodian": "11111111111111111111111111111111", - "epoch": 0, - "unixTimestamp": 0 - }, - "rentExemptReserve": "2282880" - }, - "stake": { - "creditsObserved": 227041787, - "delegation": { - "activationEpoch": "221", - "deactivationEpoch": "18446744073709551615", - "stake": "179062913955311", - "voter": "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF", - "warmupCooldownRate": 0.25 + "stake": { + "creditsObserved": 227041787, + "delegation": { + "activationEpoch": "221", + "deactivationEpoch": "18446744073709551615", + "stake": "179062913955311", + "voter": "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF", + "warmupCooldownRate": 0.25 + } } - } + }, + "type": "delegated" }, - "type": "delegated" + "program": "stake", + "space": 200 }, - "program": "stake", + "executable": false, + "lamports": 179486959040566, + "owner": "Stake11111111111111111111111111111111111111", + "rentEpoch": 18446744073709551615, "space": 200 }, - "executable": false, - "lamports": 179486959040566, - "owner": "Stake11111111111111111111111111111111111111", - "rentEpoch": 18446744073709551615, - "space": 200 - }, - "pubkey": "9hkkrNAtEpi7iDHNJgE4qse5jh3ue8r2dtyWRLSBT3fe" - } - ], + "pubkey": "9hkkrNAtEpi7iDHNJgE4qse5jh3ue8r2dtyWRLSBT3fe" + } + ], + "paginationKey": null + }, "id": 1 -} \ No newline at end of file +} diff --git a/blockapi/test/v2/api/test_solana.py b/blockapi/test/v2/api/test_solana.py index 13197707..0285252d 100644 --- a/blockapi/test/v2/api/test_solana.py +++ b/blockapi/test/v2/api/test_solana.py @@ -1,5 +1,5 @@ from decimal import Decimal -from unittest.mock import patch +from unittest.mock import call, patch import pytest from requests_mock import ANY, Mocker @@ -80,6 +80,7 @@ def test_use_base_url_in_post( das_asset_batch_response, staked_solana_response, ): + rpc_url = 'https://mainnet.helius-rpc.com/' test_addr = '5PjMxaijeVVQtuEzxK2NxyJeWwUbpTsi2uXuZ653WoHu' empty_token_accounts = '{"jsonrpc":"2.0","result":{"context":{"apiVersion":"1.17.34","slot":268207149},"value":[]},"id":1}' @@ -94,13 +95,13 @@ def test_use_base_url_in_post( ) def get_text(*args, **kwargs): - assert args[0].url == 'https://proxy/solana/' + assert args[0].url == rpc_url data = next(iterator) return data with Mocker() as m: m.post(ANY, text=get_text), - api = SolanaApi(base_url='https://proxy/solana/') + api = SolanaApi(base_url=rpc_url) api.get_balance(test_addr) @@ -237,6 +238,97 @@ def test_parse_staked_balance_skips_undelegated(): assert result.asset_type == AssetType.STAKED +def test_fetch_staked_sol_uses_v2_pagination(): + api = SolanaApi(base_url='https://mainnet.helius-rpc.com/') + address = '5PjMxaijeVVQtuEzxK2NxyJeWwUbpTsi2uXuZ653WoHu' + first_account = {'pubkey': 'first'} + second_account = {'pubkey': 'second'} + + with patch.object( + api, + '_request', + side_effect=[ + { + 'jsonrpc': '2.0', + 'id': 1, + 'result': { + 'accounts': [first_account], + 'paginationKey': 'next-page', + }, + }, + { + 'jsonrpc': '2.0', + 'id': 2, + 'result': { + 'accounts': [], + 'paginationKey': 'last-page', + }, + }, + { + 'jsonrpc': '2.0', + 'id': 3, + 'result': { + 'accounts': [second_account], + 'paginationKey': None, + }, + }, + ], + ) as request: + response = api._fetch_staked_sol(address) + + config = { + 'filters': [ + { + 'memcmp': { + 'offset': api.STAKE_AUTHORITY_OFFSET, + 'bytes': address, + 'encoding': 'base58', + } + } + ], + 'encoding': 'jsonParsed', + 'commitment': 'finalized', + 'limit': api.api_options.max_items_per_page, + } + assert request.call_args_list == [ + call( + method='getProgramAccountsV2', + params=[api.STAKE_PROGRAM_ID, config], + ), + call( + method='getProgramAccountsV2', + params=[ + api.STAKE_PROGRAM_ID, + {**config, 'paginationKey': 'next-page'}, + ], + ), + call( + method='getProgramAccountsV2', + params=[ + api.STAKE_PROGRAM_ID, + {**config, 'paginationKey': 'last-page'}, + ], + ), + ] + assert response == { + 'jsonrpc': '2.0', + 'id': 3, + 'result': [first_account, second_account], + } + + +def test_fetch_staked_sol_uses_legacy_method_for_non_helius_rpc(): + api = SolanaApi() + + with patch.object(api, '_request', return_value={'result': []}) as request: + response = api._fetch_staked_sol('address') + + request.assert_called_once() + assert request.call_args.kwargs['method'] == 'getProgramAccounts' + assert 'limit' not in request.call_args.kwargs['params'][1] + assert response == {'result': []} + + def test_das_cache_stores_sentinel_for_unknown_mint(): api = SolanaApi() unknown_mint = 'UnknownMint111111111111111111111111111111111' diff --git a/blockapi/v2/api/solana.py b/blockapi/v2/api/solana.py index 7333cd1b..1e59914c 100644 --- a/blockapi/v2/api/solana.py +++ b/blockapi/v2/api/solana.py @@ -1,6 +1,7 @@ import json import logging from typing import Optional, Union +from urllib.parse import urlparse from cytoolz import reduceby from requests import Response @@ -79,6 +80,7 @@ class SolanaApi(CustomizableBlockchainApi, BalanceMixin): TOKEN_2022_PROGRAM_ID = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' STAKE_PROGRAM_ID = 'Stake11111111111111111111111111111111111111' STAKE_AUTHORITY_OFFSET = 44 + HELIUS_RPC_DOMAIN = 'helius-rpc.com' DAS_BATCH_SIZE = 1000 _JSONRPC_INVALID_PARAMS = -32602 @@ -314,25 +316,47 @@ def _build_coin_from_das_asset(self, asset: dict) -> Optional[Coin]: def _fetch_staked_sol(self, address: str) -> dict: """Fetch staked SOL accounts for a given address.""" - return self._request( - method='getProgramAccounts', - params=[ - self.STAKE_PROGRAM_ID, + config = { + 'filters': [ { - 'filters': [ - { - 'memcmp': { - 'offset': self.STAKE_AUTHORITY_OFFSET, - 'bytes': address, - 'encoding': 'base58', - } - } - ], - 'encoding': 'jsonParsed', - 'commitment': 'finalized', - }, + 'memcmp': { + 'offset': self.STAKE_AUTHORITY_OFFSET, + 'bytes': address, + 'encoding': 'base58', + } + } ], + 'encoding': 'jsonParsed', + 'commitment': 'finalized', + } + + hostname = urlparse(self.base_url).hostname or '' + is_helius_rpc = hostname == self.HELIUS_RPC_DOMAIN or hostname.endswith( + f'.{self.HELIUS_RPC_DOMAIN}' ) + if not is_helius_rpc: + return self._request( + method='getProgramAccounts', + params=[self.STAKE_PROGRAM_ID, config], + ) + + config['limit'] = self.api_options.max_items_per_page + accounts = [] + + while True: + response = self._request( + method='getProgramAccountsV2', + params=[self.STAKE_PROGRAM_ID, config], + ) + page = response['result'] + accounts.extend(page['accounts']) + + pagination_key = page.get('paginationKey') + if pagination_key is None: + response['result'] = accounts + return response + + config = {**config, 'paginationKey': pagination_key} # ── Balance parsing ──────────────────────────────────────── @@ -373,7 +397,7 @@ def _parse_staked_balance(self, response: dict) -> Optional[BalanceItem]: def _parse_rent_reserve( self, staked_sol: BalanceItem, raw_staked_sol: dict ) -> BalanceItem: - """Parse rent reserve from getProgramAccounts response. + """Parse rent reserve from a program accounts response. Uses result[].account.lamports already returned by _fetch_staked_sol, avoiding a separate getMultipleAccounts call. From 70403085fa57382509e29ca594eecc7645dfb788 Mon Sep 17 00:00:00 2001 From: Zbynek Masler Date: Thu, 3 Sep 2026 16:40:22 +0200 Subject: [PATCH 2/4] perf: reduce Helius stake pagination requests --- blockapi/test/v2/api/test_solana.py | 2 +- blockapi/v2/api/solana.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/blockapi/test/v2/api/test_solana.py b/blockapi/test/v2/api/test_solana.py index 0285252d..2bd0dbb1 100644 --- a/blockapi/test/v2/api/test_solana.py +++ b/blockapi/test/v2/api/test_solana.py @@ -288,7 +288,7 @@ def test_fetch_staked_sol_uses_v2_pagination(): ], 'encoding': 'jsonParsed', 'commitment': 'finalized', - 'limit': api.api_options.max_items_per_page, + 'limit': api.HELIUS_PROGRAM_ACCOUNTS_PAGE_SIZE, } assert request.call_args_list == [ call( diff --git a/blockapi/v2/api/solana.py b/blockapi/v2/api/solana.py index 1e59914c..708c32d6 100644 --- a/blockapi/v2/api/solana.py +++ b/blockapi/v2/api/solana.py @@ -81,6 +81,7 @@ class SolanaApi(CustomizableBlockchainApi, BalanceMixin): STAKE_PROGRAM_ID = 'Stake11111111111111111111111111111111111111' STAKE_AUTHORITY_OFFSET = 44 HELIUS_RPC_DOMAIN = 'helius-rpc.com' + HELIUS_PROGRAM_ACCOUNTS_PAGE_SIZE = 5000 DAS_BATCH_SIZE = 1000 _JSONRPC_INVALID_PARAMS = -32602 @@ -340,7 +341,7 @@ def _fetch_staked_sol(self, address: str) -> dict: params=[self.STAKE_PROGRAM_ID, config], ) - config['limit'] = self.api_options.max_items_per_page + config['limit'] = self.HELIUS_PROGRAM_ACCOUNTS_PAGE_SIZE accounts = [] while True: From 97ff40b269f6da33c8e7909ee53fc061a015ac49 Mon Sep 17 00:00:00 2001 From: Zbynek Masler Date: Thu, 3 Sep 2026 21:58:46 +0200 Subject: [PATCH 3/4] fix: stop repeated Solana pagination cursors --- blockapi/test/v2/api/test_solana.py | 27 +++++++++++++++++++++++++++ blockapi/v2/api/solana.py | 4 ++++ 2 files changed, 31 insertions(+) diff --git a/blockapi/test/v2/api/test_solana.py b/blockapi/test/v2/api/test_solana.py index 2bd0dbb1..b38d60d4 100644 --- a/blockapi/test/v2/api/test_solana.py +++ b/blockapi/test/v2/api/test_solana.py @@ -6,6 +6,7 @@ from blockapi.test.v2.api.conftest import read_file from blockapi.v2.api import SolanaApi, SolscanApi +from blockapi.v2.base import ApiException from blockapi.v2.models import ( AssetType, BalanceItem, @@ -317,6 +318,32 @@ def test_fetch_staked_sol_uses_v2_pagination(): } +def test_fetch_staked_sol_rejects_repeated_pagination_key(): + api = SolanaApi(base_url='https://mainnet.helius-rpc.com/') + repeated_page = { + 'result': { + 'accounts': [], + 'paginationKey': 'same-page', + } + } + terminal_page = { + 'result': { + 'accounts': [], + 'paginationKey': None, + } + } + + with patch.object( + api, + '_request', + side_effect=[repeated_page, repeated_page, terminal_page], + ) as request: + with pytest.raises(ApiException, match='repeated pagination key'): + api._fetch_staked_sol('address') + + assert request.call_count == 2 + + def test_fetch_staked_sol_uses_legacy_method_for_non_helius_rpc(): api = SolanaApi() diff --git a/blockapi/v2/api/solana.py b/blockapi/v2/api/solana.py index 708c32d6..29a141f9 100644 --- a/blockapi/v2/api/solana.py +++ b/blockapi/v2/api/solana.py @@ -343,6 +343,7 @@ def _fetch_staked_sol(self, address: str) -> dict: config['limit'] = self.HELIUS_PROGRAM_ACCOUNTS_PAGE_SIZE accounts = [] + seen_pagination_keys = set() while True: response = self._request( @@ -356,7 +357,10 @@ def _fetch_staked_sol(self, address: str) -> dict: if pagination_key is None: response['result'] = accounts return response + if pagination_key in seen_pagination_keys: + raise ApiException('Solana RPC returned a repeated pagination key') + seen_pagination_keys.add(pagination_key) config = {**config, 'paginationKey': pagination_key} # ── Balance parsing ──────────────────────────────────────── From b1d5a2288eafddd41853559227df97410db6f6ea Mon Sep 17 00:00:00 2001 From: Zbynek Masler Date: Thu, 3 Sep 2026 22:00:17 +0200 Subject: [PATCH 4/4] test: cover Solana staking response formats --- blockapi/test/v2/api/test_solana.py | 32 ++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/blockapi/test/v2/api/test_solana.py b/blockapi/test/v2/api/test_solana.py index b38d60d4..8202dc70 100644 --- a/blockapi/test/v2/api/test_solana.py +++ b/blockapi/test/v2/api/test_solana.py @@ -1,3 +1,4 @@ +import json from decimal import Decimal from unittest.mock import call, patch @@ -75,15 +76,26 @@ def test_use_base_url(): assert api.base_url == 'https://api.mainnet-beta.solana.com/' -def test_use_base_url_in_post( +@pytest.mark.parametrize( + ('rpc_url', 'uses_v2_staking'), + [ + ('https://mainnet.helius-rpc.com/', True), + ('https://proxy/solana/', False), + ], +) +def test_get_balance_supports_helius_and_legacy_staking_responses( sol_balance_response, token_accounts_response, das_asset_batch_response, staked_solana_response, + rpc_url, + uses_v2_staking, ): - rpc_url = 'https://mainnet.helius-rpc.com/' test_addr = '5PjMxaijeVVQtuEzxK2NxyJeWwUbpTsi2uXuZ653WoHu' empty_token_accounts = '{"jsonrpc":"2.0","result":{"context":{"apiVersion":"1.17.34","slot":268207149},"value":[]},"id":1}' + staking_response = json.loads(staked_solana_response) + if not uses_v2_staking: + staking_response['result'] = staking_response['result']['accounts'] iterator = iter( [ @@ -91,7 +103,7 @@ def test_use_base_url_in_post( token_accounts_response, empty_token_accounts, das_asset_batch_response, - staked_solana_response, + json.dumps(staking_response), ] ) @@ -101,9 +113,19 @@ def get_text(*args, **kwargs): return data with Mocker() as m: - m.post(ANY, text=get_text), + m.post(ANY, text=get_text) api = SolanaApi(base_url=rpc_url) - api.get_balance(test_addr) + balances = api.get_balance(test_addr) + + staking_balances = { + balance.asset_type: balance.balance_raw + for balance in balances + if balance.asset_type in {AssetType.STAKED, AssetType.LOCKED} + } + assert staking_balances == { + AssetType.STAKED: Decimal('179062913955311'), + AssetType.LOCKED: Decimal('424045085255'), + } def test_build_coin_from_das_asset():