From 52e6008af546c6569ad8e2eb9fc95af1ba79c67b Mon Sep 17 00:00:00 2001 From: Zbynek Masler Date: Fri, 21 Aug 2026 19:46:05 +0200 Subject: [PATCH] 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.